Une histoire de pizzas : les actions
(lecture : 25 minutes) â sĂ©rie : Ergogames, une histoire de pizzas
Hey salut, ça fait un bail hein ? Hein, quoi⊠plus dâun an ? Le temps passe vite đ Je dois vous avouer que lâĂ©criture de cet article mâangoissait lĂ©gĂšrement parce quâil sâagissait dâun gros morceau. Jâai donc laissĂ© trainer quelques semaines⊠puis mois⊠puis une annĂ©e entiĂšre. Mais nous y voilĂ Â !
Pour redonner le contexte, jâai donnĂ© un coup de main en 2019 Ă deux anciennes collĂšgue pour dĂ©velopper un mini-jeu. Le projet global est un site qui invite Ă dĂ©couvrir diffĂ©rents principes dâergonomie Ă travers des jeux illustrant les concepts : les Ergogames. Jâai acceptĂ© Ă condition que le code soit ouvert (il lâest donc). Le jeu sur lequel jâai bossĂ© consiste Ă dĂ©placer un personnage (Meiko) dans une cuisine en cliquant de case en case. Lâobjectif est de lui faire fabriquer une pizza. Le principe illustrĂ© est celui des actions minimales.
Jâai commencĂ© une sĂ©rie dâarticles pour expliquer comment jâavais conçu ce jeu alors que je nây connaissais pas grand-chose auparavant. Jây explique les Ă©tapes pas Ă pas en tentant dâĂȘtre aussi pĂ©dagogique que possible. Vous ĂȘtes invitĂ©â§es Ă lire les articles prĂ©cĂ©dents pour vous y retrouver.
Durant les Ă©pisodes prĂ©cĂ©dents, on sâest surtout concentrĂ© sur le visuel et les dĂ©placements. On a donc dĂ©sormais un personnage qui peut aller de case en case, tout en Ă©tant arrĂȘtĂ© par des Ă©lĂ©ments « bloquants » (le frigo, le four, etc.) Dans le dernier article, on avait ajoutĂ© la liste des Ă©tapes du jeu. Il nous faut maintenant la rendre dynamique, en fonction des actions de Meiko !
Le résultat du code de cet article se trouve ici. Je vous invite à en lire le code source, les parties ajoutées par cet article sont commentées.
Cette fois-ci, on ne va modifier que le JavaScript. Il nous faut commencer par adapter quelques éléments.
PremiÚrement, en fonction des étapes du jeu, Meiko et la pizza vont changer de
« look ». Par exemple, la pizza se trouve Ă lâĂ©tat de boule au dĂ©but du jeu,
puis Meiko va lâĂ©taler, donc il faut adapter le visuel. Du cĂŽtĂ© de Meiko, son
look sâadapte en fonction des ingrĂ©dients quâil porte (ou non). Ce quâon va
faire, câest ajouter un composant look. Sa valeur sera modifiĂ©e en fonction
des actions effectuées par le joueur. On va également modifier le composant
assets en lâindexant par les valeurs possibles du composant look.
let gameStore = [
{
id: 'meiko',
// Meiko va changer de "look" en fonction des actions, on ajoute
// donc un nouveau composant pour le mémoriser
look: 'normal',
assets: {
// On modifie notre composant "assets" pour indexer dâabord par
// le look et on conserve le systĂšme de "direction"
normal: {
bottom: 'assets/meiko-face.svg',
right: 'assets/meiko-droite.svg',
left: 'assets/meiko-gauche.svg',
top: 'assets/meiko-dos.svg',
},
mozza: {
bottom: 'assets/meiko-face-mozza.svg',
right: 'assets/meiko-droite-mozza.svg',
left: 'assets/meiko-gauche-mozza.svg',
top: 'assets/meiko-dos.svg',
},
sauce: {
bottom: 'assets/meiko-face-sauce.svg',
right: 'assets/meiko-droite-sauce.svg',
left: 'assets/meiko-gauche-sauce.svg',
top: 'assets/meiko-dos.svg',
},
pizza: {
bottom: 'assets/meiko-face-pizza.svg',
right: 'assets/meiko-droite-pizza.svg',
left: 'assets/meiko-gauche-pizza.svg',
top: 'assets/meiko-dos.svg',
},
},
// ...
},
// Puis on fait la mĂȘme chose avec la pizza (note : dans les articles
// précédents, l'id était `dough`, j'ai modifié en `pizza` mais c'est
// un détail)
{
id: 'pizza',
// la pizza aussi change de look au fur et Ă mesure de la partie,
// mais pas de direction pour elle !
look: 'doughBall',
assets: {
doughBall: 'assets/pate-pizza-boule.png',
dough: 'assets/pate-pizza.svg',
tomato: 'assets/pate-tomate.svg',
mozza: 'assets/pizza.svg',
profile: 'assets/pizza-plat.svg',
},
/// ...
},
// ...
};
Maintenant quâon a modifiĂ© le fonctionnement du composant assets, il faut
prendre ça en compte dans le code qui lâutilise. Heureusement pour nous il
sâagit dâune seule petite fonction simple : entityAssetImage. On la retrouve
dans nos méthodes VueJS :
const app = new Vue({
methods: {
// On modifie lĂ©gĂšrement le rendu ici puisquâon a fait Ă©voluer le
// composant "assets" pour fonctionner avec le nouveau composant
// "look".
entityAssetImage(entity) {
if (entity.assets == null || entity.look == null) {
return '';
}
// on récupÚre l'asset correspondant au "look" actuel
const asset = entity.assets[entity.look];
if (entity.direction) {
// si l'entité a un composant direction, il faut encore
// piocher la bonne image
return asset[entity.direction];
} else {
// sinon, on retourne la valeur telle quelle
return asset;
}
},
// ...
},
// ...
});
Désormais, lorsque la valeur de look changera, VueJS changera pour nous
lâimage affichĂ©e Ă lâĂ©cran. IdĂ©alement il faudrait faire quelques vĂ©rifications
supplémentaires pour rendre le code plus robuste, mais on est sur un mini-jeu
pas critique, je privilégie la lisibilité en allant droit au but.
DeuxiÚme élément important à ajouter à notre jeu : le systÚme qui va exécuter
les actions. Il va sâagir de fonctions exĂ©cutĂ©es quand on cliquera sur le bon
Ă©lĂ©ment du jeu et qui modifieront lâĂ©tat interne du jeu (gameStore). Ces
fonctions vont donc ĂȘtre rattachĂ©es aux diffĂ©rentes entitĂ©s via un nouveau
composant : exec. Elles seront exĂ©cutĂ©es lorsquâon cliquera sur lâentitĂ©
correspondante (au sein de la fonction onClickSystem). Une facilité du jeu,
câest quâil nây a toujours quâune seule chose Ă faire Ă la fois, soit un seul
composant exec Ă la fois. Ăa nous Ă©vite quelques nĆuds au cerveau. La
premiÚre action consiste à étaler la pùte, il faut donc rajouter le composant
exec à la pizza-pùte :
let gameStore = [
{
id: 'pizza',
// Le nouveau composant exec qui permet de faire avancer le jeu !
// La premiÚre action consiste à étaler la pùte, on a donc un seul
// composant exec qui pointe vers l'action `rollOutDough`.
exec: rollOutDough,
/// ...
},
// ...
};
La fonction elle-mĂȘme sera Ă©crite un peu plus loin. En attendant on va ajouter
lâexĂ©cution de la fonction au sein de onClickSystem, exĂ©cutĂ© lorsquâon clique
sur une case :
function onClickSystem(e, store) {
// ...
const meiko = getEntity(store, 'meiko');
if (!positionIsAccessibleFor(meiko, position)) {
return store;
}
// Avant de bouger Meiko, on recherche les entités situées sur la case
// cliquée : s'il en existe avec le composant "exec", alors on appelle
// la fonction associée en lui passant le store et la position. La
// fonction doit retourner un nouveau store mis Ă jour.
let updatedStore = store;
const entitiesAtPosition = searchEntitiesAt(updatedStore, position);
entitiesAtPosition.forEach((entity) => {
if (typeof entity.exec === 'function') {
updatedStore = entity.exec(updatedStore, position);
}
});
// ...
}
Rien de sorcier ici, on se contente du fait que JavaScript nous permet de rĂ©fĂ©rencer une fonction par son nom et de lâexĂ©cuter via cette rĂ©fĂ©rence en ajoutant les parenthĂšses et les paramĂštres.
On pourrait passer tout de suite Ă lâĂ©criture des actions, mais il va nous
manquer une derniĂšre mini-fonction. On va en effet avoir besoin de supprimer
des entités du gameStore (ex. la sauce tomate va disparaßtre une fois que
Meiko lâaura attrapĂ©e).
// On a désormais besoin de supprimer des entités du jeu. On ne garde
// (`filter`) que les entités dont l'id est différent de celui passé en
// argument.
function removeEntity(store, id) {
return store.filter((entity) => entity.id !== id);
}
Nous avons dĂ©sormais tous les Ă©lĂ©ments nĂ©cessaires pour faire avancer le jeu, il nây a plus quâĂ Ă©crire les actions ! Une action est en fait trĂšs simple :
- elle commence par mettre Ă jour lâĂ©tape courante (lâentitĂ©
currentStep)Â ; - elle ajoute un composant
exec, pointant vers la prochaine action, sur lâentitĂ© correspondante ; - et supprime le composant
execde lâentitĂ© actuelle ; - elle peut Ă©galement changer le look des entitĂ©s, en supprimer, etc.
Commençons par notre premiÚre action : rollOutDough.
function rollOutDough(store) {
let updatedStore = store;
// on commence par mettre Ă jour lâĂ©tape actuelle
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_GRAB_TOMATO' });
// puis on "installe" un composant exec sur l'entité "tomato" puisque
// câest la prochaine destination de Meiko
updatedStore = setEntityComponents(updatedStore, 'tomato', { exec: grabTomato });
updatedStore = setEntityComponents(updatedStore, 'pizza', {
// Meiko vient dâĂ©taler la pĂąte, il faut changer le "look" de la
// pizza
look: 'dough',
// comme on nâa plus rien Ă faire pour le moment sur la pizza, on
// pense à enlever son composant "exec" (sinon Meiko pourra étaler
// la pizza plusieurs fois de suite).
exec: null,
});
// et finalement, on renvoie le nouveau store
return updatedStore;
}
On le voit, il sâagit uniquement de modifier le store passĂ© en paramĂštre Ă
lâaide de la fonction setEntityComponents quâon avait Ă©crite dans un article
prĂ©cĂ©dent. Si vous avez rĂ©ussi Ă suivre jusquâici, ça devrait ĂȘtre vraiment
simple à comprendre ! Le fait de changer le currentStep permet de changer
visuellement lâĂ©tape courante : câest le code Ă©crit dans lâarticle prĂ©cĂ©dent.
Nous avons maintenant une nouvelle action Ă Ă©crireâŠ
function grabTomato(store) {
let updatedStore = store;
// idem, on met Ă jour lâĂ©tape actuelle
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_SPREAD_TOMATO' });
// puis on ajoute un "exec" sur la pizza car l'action suivante porte
// sur elle
updatedStore = setEntityComponents(updatedStore, 'pizza', { exec: spreadTomato });
// on change le look de Meiko qui porte maintenant le pot de tomate
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'sauce' });
// et on enlÚve l'entité "tomato" car elle n'est plus présente sur le
// plan de travail. On est d'accord que le pot de tomate est toujours
// visible, mais on triche en lâintĂ©grant visuellement Ă l'asset de
// Meiko.
updatedStore = removeEntity(updatedStore, 'tomato');
return updatedStore;
}
En supprimant lâentitĂ© tomato, on a supprimĂ© par la mĂȘme occasion son exec.
Mais⊠oh non, on a encore une action Ă Ă©crire ! Vous lâaurez compris, on va
continuer exactement de la mĂȘme maniĂšre pour chaque action, car elles
sâenchaĂźnent les unes aprĂšs les autres. Je vous donne tout le code dâun coup.
Il y a quelques passages oĂč jâai trichĂ© par rapport Ă la version initiale du
jeu pour simplifier les explications. Ces passages sont signalés en
commentaire.
function spreadTomato(store) {
let updatedStore = store;
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_GRAB_MOZZA' });
updatedStore = setEntityComponents(updatedStore, 'mozza', { exec: grabMozza });
updatedStore = setEntityComponents(updatedStore, 'pizza', {
look: 'tomato',
exec: null,
});
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'normal' });
return updatedStore;
}
function grabMozza(store) {
let updatedStore = store;
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_SPREAD_MOZZA' });
updatedStore = setEntityComponents(updatedStore, 'pizza', { exec: spreadMozza });
updatedStore = removeEntity(updatedStore, 'mozza');
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'mozza' });
return updatedStore;
}
function spreadMozza(store) {
let updatedStore = store;
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_GRAB_PIZZA_1' });
updatedStore = setEntityComponents(updatedStore, 'pizza', {
look: 'mozza',
exec: grabUncookedPizza,
});
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'normal' });
return updatedStore;
}
function grabUncookedPizza(store) {
let updatedStore = store;
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_PUT_PIZZA_IN_OVEN' });
updatedStore = setEntityComponents(updatedStore, 'oven', { exec: putPizzaInOven });
// Ici on triche un peu. On ne veut plus afficher la pizza dans le jeu.
// Dans la version initiale, je supprimais le composant et le recréait
// plus tard. Ăa se basait cependant sur un mĂ©canisme que je n'ai pas
// expliqué dans ma série d'articles, donc je dois faire différemment.
// Ici, je vire simplement le "look" pour cacher la pizza.
updatedStore = setEntityComponents(updatedStore, 'pizza', {
look: null,
exec: null,
});
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'pizza' });
return updatedStore;
}
function putPizzaInOven(store, position) {
let updatedStore = store;
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_GRAB_PIZZA_2' });
updatedStore = setEntityComponents(updatedStore, 'oven', {
exec: null,
});
// la suite de l'action précédente : on redonne un look à la pizza, et
// on change sa position Ă l'emplacement qui vient d'ĂȘtre cliquĂ©.
updatedStore = setEntityComponents(updatedStore, 'pizza', {
position,
look: 'profile',
// je triche encore ! Dans la version initiale on est sensé
// attendre la cuisson de la pizza (5 secondes). Ăa se base sur un
// autre systÚme que je n'ai pas expliqué dans la série d'articles,
// donc je saute l'Ă©tape de la cuisson (STEP_BAKE_PIZZA). Peut-ĂȘtre
// que j'en parlerai dans un article bonus parce que le mécanisme
// est intéressant.
exec: grabCookedPizza,
});
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'normal' });
return updatedStore;
}
function grabCookedPizza(store) {
let updatedStore = store;
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_DELIVER_PIZZA' });
updatedStore = setEntityComponents(updatedStore, 'hatch', { exec: deliverPizza });
updatedStore = setEntityComponents(updatedStore, 'pizza', {
look: null,
exec: null,
});
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'pizza' });
return updatedStore;
}
function deliverPizza(store, position) {
let updatedStore = store;
// et c'est la fin ! Remarquez qu'on vire le dernier "exec" sans en
// rajouter un autre.
updatedStore = setEntityComponents(updatedStore, 'currentStep', { value: 'STEP_FINISH' });
updatedStore = setEntityComponents(updatedStore, 'hatch', { exec: null });
updatedStore = setEntityComponents(updatedStore, 'pizza', {
position,
look: 'profile',
});
updatedStore = setEntityComponents(updatedStore, 'meiko', { look: 'normal' });
return updatedStore;
}
Les actions se ressemblent vraiment toutes, la difficultĂ© rĂ©side dans le fait de ne pas se tromper dans lâenchaĂźnement des Ă©tapes et des choses Ă faire. Un avantage Ă la simplicitĂ© de ces fonctions, câest quâon peut Ă©crire des tests pour vĂ©rifier leur comportement extrĂȘmement facilement.
Voici donc pour le dernier article « officiel » de cette sĂ©rie ! Celui-ci Ă©tait peut-ĂȘtre un peu plus dense que les autres, bien quâau final pas excessivement compliquĂ©. Pour rappel, vous pouvez retrouver la version de cet article ici.
Jâai Ă©tĂ© surpris dâarriver Ă me replonger dans le code aussi facilement aprĂšs plus dâun an de pause dans lâĂ©criture de la sĂ©rie, ce qui me conforte dans la qualitĂ© de ce que jâai dĂ©veloppĂ© (malgrĂ© les facilitĂ©s et « hacks » utilisĂ©s par endroits). Jâaurais encore bien des choses Ă raconter : la mise en cache des images, les mĂ©canismes asynchrones (dont la gestion du chronomĂštre), les Ă©crans de dĂ©but et fin de jeu, etc. Certaines de ces choses mâintĂ©ressent Ă Ă©crire, dâautres moins. Dans tous les cas je ne veux pas mâengager Ă les Ă©crire, donc si je le fais ce sera Ă travers des articles « bonus ».
Jâai pris beaucoup de plaisir Ă expliquer les choses de maniĂšre didactiques, en dĂ©coupant les Ă©tapes autant que possible et en commentant un maximum de code. Je ne sais pas si beaucoup de monde mâaura lu en intĂ©gralitĂ©, mais je serais ravi si ça a pu ĂȘtre utile (ou mĂȘme simplement intĂ©resser quelquâun).