Une histoire de pizzas : les actions

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