Une histoire de pizzas : les déplacements

Je suis en forme alors voici le troisième article qui relate le développement du jeu que j’ai conçu pour les Ergogames. On a déjà vu comment afficher la grille et comment gérer les objets, nous allons aujourd’hui voir comment déplacer notre personnage, Meiko.

Je vous invite très fortement à lire les autres articles si ce n’est pas déjà fait, vous risquez autrement de ne rien piger à celui-ci.

Le but ici est de pouvoir viser une case avec la souris et de déplacer Meiko en cliquant. Là encore, on a tout un tas de solutions possibles. Ma première approche a été de vouloir rendre les entités « cliquables », mais comme on veut potentiellement cliquer sur des cellules sans entité, j’étais bloqué.

Ma seconde approche a été de faire en sorte de pouvoir cliquer sur les .board-cell qui représentent les cellules de notre tableau (ce sont celles du premier article). Le problème ici est que les entités survolent les cellules et risquent de récupérer l’action du « clic » sans le propager aux cases en dessous, pas cool. Alors on pourrait vouloir rendre tout ce petit monde « cliquable », mais ça deviendrait galère à gérer parce que les clics ne portent plus sur les mêmes types d’objets et il faudrait les distinguer.

Bref, j’ai choisi une solution qui me semble plutôt élégante : j’ai posé un lien unique (balise <a />) par-dessus la zone de jeu, puis j’ai déterminé en JavaScript la case sur laquelle porte le clic.

<div class="board">
    <!-- ... -->

    <!--
        On ajoute un lien qui recouvre l’ensemble de la zone de jeu et qui
        va récupérer les clics du ou de la joueuse pour ensuite modifier
        l’état du jeu.
    -->
    <a href="#" @click="onBoardClick" class="board-click-zone"></a>
</div>

<script>
    // ...

    const app = new Vue({
        // ...

        methods: {
            // ...

            onBoardClick(e) {
                // C'est ici qu'on connecte le tout : onBoardClick est appelé
                // quand on clique sur le lien .board-click-zone. On passe
                // l'évènement généré à un "système" qui va modifier le store
                // (ou non) en conséquence. Le store retourné est réassigné à
                // notre objet VueJS, ce qui va entrainer un rafraichissement
                // du HTML si besoin. Magique.
                this.store = onClickSystem(e, this.store);
            },
        },
    });

    // Notre premier "vrai" système. C'est une fonction qui prend un store
    // en entrée et en retourne un en sortie (modifié... ou non).
    // L'évènement qui est également passé en argument permet de déterminer
    // quelle zone du jeu a été "activée" par le ou la joueuse.
    function onClickSystem(e, store) {
        // On fait en sorte que le clic ne soit pas actionné (sinon l'écran va
        // remonter en haut de l'écran et ce sera pas agréable)
        e.preventDefault();

        // e.layerX et e.layerY donnent la position en pixels de là où l’on a
        // cliqué. Il suffit de diviser cette valeur par la taille d’une
        // cellule et d'arrondir le résultat pour savoir sur quelle cellule on
        // a cliqué... enfin presque, on retranche encore "1" parce que notre
        // tableau commence à l'indice "0".
        // Exemple : si la cellule à l’indice 0 fait 10px et que l’on clique
        // sur le pixel "10", le calcul 10/10 est égal à 1 ; il faut donc
        // retrancher encore 1 pour retrouver notre indice "0".
        // Et si vous avez rien compris c'est pas dramatique, en plus j'ai pas
        // fait comme ça dans le jeu de base :)
        const x = Math.ceil(e.layerX / CSS_CELL_SIZE) - 1;
        const y = Math.ceil(e.layerY / CSS_CELL_SIZE) - 1;

        // On change la position de Meiko pour correspondre à l'endroit visé
        store.meiko.position = { x, y };

        // Et on retourne le store ainsi modifié
        return store;
    }
</script>

<style type="text/css">
    /* ... */

    .board {
        position: relative;
        /*
           On limite la largeur du tableau pour ne pas pouvoir cliquer en
           dehors de la zone de jeu. Ça aurait pu être fait plus tôt mais on
           s’en fichait un peu puisque ça ne posait pas encore de problème.
        */
        width: calc(6 * var(--cell-size));
    }

    /*
       Rien de compliqué : .board-click-zone est positionné relativement à
       .board et le recouvre totalement, c'est donc bien lui qui va récupérer
       tous les clics. Oh, et on n'oublie pas le z-index pour nous assurer que
       ça passera par-dessus les entités.
    */
    .board-click-zone {
        position: absolute;
        top: 0;
        bottom: 0;
        right: 0;
        left: 0;
        z-index: 100;
    }

    /* ... */

    /* On s'assure que Meiko est toujours visible au-dessus des autres entités. */
    .board-cell-entity.meiko {
        z-index: 20;

        background-color: #fff;
        border: 2px solid green;
    }
</style>

Avec ça, on pourrait être content puisque Meiko peut désormais se déplacer, et cela avec peu de nouveau code. C’est sans compter un petit détail que les personnes connaissant VueJS ou autres bibliothèques de ce type auront peut-être remarquées : le store est un Object que l’on passe par référence à onClickSystem (enfin presque, je rentre pas dans les détails). La conséquence est que lorsqu’on modifie sa valeur, le store initial (stocké dans data) est également modifié et l’assignation dans onBoardClick n’a absolument aucun impact puisqu’on se contente de lui réassigner le même Object. Une manière de mieux comprendre est de modifier cette méthode :

onBoardClick(e) {
    const newStore = onClickSystem(e, this.store);
    console.log({
        oldPosition: this.store.meiko.position,
        newPosition: newStore.meiko.position,
    });
},

Ici j’ai logué la position de Meiko depuis le store initial ainsi que celle du nouveau store. J’ai également enlevé l’assignation à this.store. Si vous regardez le résultat dans une console, vous remarquerez toutefois que les deux valeurs sont identiques et que Meiko se déplace quand même : l’assignation n’avait donc bien aucun intérêt ! Ici il faut comprendre que VueJS ne devrait pas être capable de détecter un changement dans le store et donc d’actualiser la vue ; ce qu’il fait pourtant. La réponse à cette bizarrerie est donnée dans la documentation de VueJS qui nous explique que la valeur de l’objet data est gérée d’une manière un peu spéciale, permettant de surveiller les changements et donc de « réagir » à ceux-ci. C’est pratique, mais c’est accompagné d’avertissements importants concernant l’ajout ou la suppression de données dans cet objet qui ne sont pas, eux, détectés à moins d’utiliser des méthodes particulières. On peut illustrer cela en changeant légèrement le code de onClickSystem :

function onClickSystem(e, store) {
    e.preventDefault();

    // Ici on ne change plus la position de Meiko mais on tente de la supprimer
    delete store.meiko.position;

    return store;
}

Si vous tentez de voir ce que cela donne, vous verrez que Meiko ne bougera pas. Le résultat attendu est toutefois qu’il disparaisse car la méthode entityPosition est censée retourner un objet vide pour les entités ne possèdant pas de position (voir le deuxième article pour comprendre). Ici, VueJS n’a tout simplement pas détecté qu’il y avait eu un changement dans le store. La bonne manière de faire serait en fait de remplacer la ligne du delete par :

Vue.delete(store.meiko, 'position');

Ça marche, mais ce n’est finalement pas vraiment ce que je souhaite. En effet, ici les modifications dans le store vont être directement (ou presque) prises en compte par VueJS qui va impacter cela dans la vue. J’aimerais pour ma part pouvoir faire celles-ci par « lots » et assigner le nouveau store dans onBoardClick, comme je l’ai fait dans mon code. Mon idée est également de ne pas avoir d’effet de bord à l’intérieur de mon système. Celui-ci doit prendre un store en entrée, puis en retourner un en sortie sans toucher au premier. Cela facilite à mon sens la compréhension du code et l’écriture de tests.

Pour réaliser cela il faut dupliquer le store, mais c’est un peu pénible avec un Object JavaScript (c’était en tout cas mon avis à l’époque, mais je réalise maintenant que j’ai totalement surestimé la complexité, je reviens là-dessus en fin d’article). J’ai à ce moment-là pris la décision de revoir le format de données de mon store en le transformant en un Array, que j’aurai plus de facilité à manipuler. Cela demande toutefois quelques adaptations et il vaut mieux le faire dès maintenant alors que l’on a peu de code :

<div class="board">
    <!-- ... -->

    <!--
        Dans le template HTML, on change l'appel à `id` qui a été déplacé
        au sein des entités
    -->
    <div
      v-for="entity in store"
      :key="entity.id"
      :class="['board-cell-entity', entity.id]"
      :style="entityPosition(entity)"
    >
        <div v-if="entity.label" class="entity-label">
            {{ entity.label }}
        </div>
    </div>

    <!-- ... -->
</div>

<script>
    // On revoit la structure de données du store en le transformant en un
    // Array pour faciliter la manipulation des données.
    // Les anciennes clés qui servaient à identifier les entités sont déplacées
    // au sein des entités.
    let gameStore = [
        {
            id: 'meiko',
            label: 'Meiko',
            size: { width: 1, height: 1 },
            position: { x: 3, y: 2 },
        },

        {
            id: 'mozza',
            label: 'Mozzarella',
            size: { width: 1, height: 1 },
            position: { x: 1, y: 3 },
        },
        {
            id: 'tomato',
            label: 'Sauce tomate',
            size: { width: 1, height: 1 },
            position: { x: 2, y: 5 },
        },
        {
            id: 'dough',
            label: 'Pâte à pizza',
            size: { width: 1, height: 1 },
            position: { x: 5, y: 3 },
        },

        // etc.
    ];

    // ...
</script>

Ce changement est relativement indolore et a finalement assez peu d’impact (si ce n’est sur la rapidité d’exécution de certaines instructions). Par contre Meiko ne peut malheureusement plus bouger ! On va devoir faire une dernière adaptation au code :

function onClickSystem(e, store) {
    e.preventDefault();

    const x = Math.ceil(e.layerX / CSS_CELL_SIZE) - 1;
    const y = Math.ceil(e.layerY / CSS_CELL_SIZE) - 1;

    // On change la position de Meiko pour correspondre à l'endroit visé à
    // l'aide de la fonction écrite juste en dessous.
    const position = { x, y };
    let updatedStore = setEntityComponents(store, 'meiko', { position });

    // Et on retourne le store modifié
    return updatedStore;
}

// Cette fonction permet de modifier les composants d'une entité présente
// au sein d'un store, sans modifier ce dernier directement : un nouveau
// store est en fait retourné.
function setEntityComponents(store, id, components) {
    return store.map((entity) => {
        if (entity.id !== id) {
            return entity;
        }
        // Je ne rentre par contre pas dans le détail de la syntaxe
        // JavaScript...
        return {
            ...entity,
            ...components,
        };
    });
}

Et voilà, avec ça Meiko a désormais la possibilité de se déplacer en toute liberté dans sa cuisine. Et pour le coup il en a un peu trop puisqu’il est capable de passer d’un coin à un autre en un seul clic. Il peut également passer au-dessus des gros objets tels que le frigo, ce qui ne devrait pas être autorisé. J’avais imaginé traiter de ce sujet dans cet article, mais je me rends compte qu’il est déjà bien assez complet comme ça alors j’expliquerai comment contraindre les mouvements de notre personnage dans un prochain article !

Le code final de cet article est accessible ici (la console est toujours ton amie).

Pour terminer, je voulais revenir sur mon choix de changer de structure de données pour représenter mon store. Comme je l’ai précisé entre parenthèses, je réalise aujourd’hui que mon choix était insuffisamment justifié et j’aurais pu continuer avec un Object plutôt qu’un Array. L’impact immédiat est la complexité du code : pour accéder à mon entité meiko, je dois désormais parcourir les éléments de mon tableau un par un (complexité en O(n)), alors que je pouvais le récupérer directement avec un Object indexé par les identifiants des entités (complexité en O(1)). Ce changement est toutefois assez peu couteux dans ce jeu car nous aurons peu d’entités à manipuler (bien que nous en rajouterons plus tard).

J’ai décidé de conserver ce changement dans mon article pour trois raisons. La première est très basique : je reste proche de mon code initial. La seconde est d’illustrer que l’on ne fait pas toujours les bons choix d’implémentation du premier coup (surtout quand on tâtonne) et qu’il est pertinent de prendre du recul sur ce qu’on est en train de faire. C’est OK de revoir une structure de données, mais attention aux impacts que cela a sur le code ! Une suite de tests aurait pu être pertinente ici pour valider que nous n’avons pas de régressions dans les fonctionnalités.

Ma dernière raison est plus discutable : l’utilisation d’un Array permet de complexifier l’accès et la modification des entités, encourageant par la même occasion l’utilisation des fonctions utilitaires (ici, setEntityComponents). Je trouve cela utile car, comme on l’a vu, il faut absolument éviter d’ajouter ou supprimer un composant « à la main » dans les entités (au risque que VueJS ne détecte pas le changement). Un ou une nouvelle développeur·euse n’aura pas forcément conscience de cette particularité et perdra du temps à tenter de comprendre pourquoi son code ne marche pas. En lui empêchant certaines facilités, on l’encourage à découvrir comment ont fait les autres et on la pousse ainsi à utiliser les fonctions qui lui assure de le faire « correctement ». C’est une sorte d’ergonomie pensée à travers la complexité, je ne sais pas si ça a été théorisé mais c’est plutôt à-propos ! 😄

Revenir à la série