🏠 Accueil

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 :

  1. elle commence par mettre Ă  jour l’étape courante (l’entitĂ© currentStep) ;
  2. elle ajoute un composant exec, pointant vers la prochaine action, sur l’entitĂ© correspondante ;
  3. et supprime le composant exec de l’entitĂ© actuelle ;
  4. 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).

Revenir à la série