PROJET AUTOBLOG


Marien Fressinaud

Site original : Marien Fressinaud

⇐ retour index

Mise à jour

Mise à jour de la base de données, veuillez patienter...

Une histoire de pizzas : coup de pinceau

lundi 11 novembre 2019 à 18:00

Vous aimez les pizzas ? Vous aimez les ratons laveurs ? Alors voici déjà le cinquième épisode de ma série d’articles retraçant la conception d’un mini-jeu vidéo !

Dans le deuxième article, on ne s’était pas trop embêté pour les graphismes : on avait fait de gros blocs de couleurs moches. Il est toutefois temps de corriger ça en passant un coup de peinture sur le jeu. Notre raton va bientôt ressembler à un raton !

Le premier ingrédient qu’il nous faut, c’est un graphiste ; et surprise, je ne le suis pas. Mes anciennes collègues de uxShadow ont fait appel à Goulven Barron pour réaliser les graphismes du jeu (merci à lui !), je n’avais ainsi plus qu’à les intégrer.

Pour cet article, je me suis donc contenté de récupérer des images du jeu d’origine et de les déposer dans un répertoire assets (qu’on peut traduire par « ressources » en bon françois).

Ensuite, il ne nous reste plus qu’à attacher chaque entité à une image et à l’afficher. Pour cela, on va tout simplement créer un nouveau composant au sein des entités (asset) qui contiendra l’URL vers l’image et ajouter cette dernière au HTML à l’aide d’une balise <img />.

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

        <div
          v-for="entity in store"
          :key="entity.id"
          :class="['board-cell-entity', entity.id]"
          :style="entityPosition(entity)"
        >
            <!-- On affiche une image si l'entité possède un composant "asset" -->
            <img
                v-if="entity.asset"
                :src="entity.asset"
                alt=""
                class="entity-asset"
            />

            <!-- Sinon, on continue d'afficher le label -->
            <div v-else-if="entity.label" class="entity-label">
                {{ entity.label }}
            </div>
        </div>

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

<script>
    // ...

    let gameStore = [
        // On se contente d'ajouter un composant "asset" à chacune de nos
        // entités. Il s’agit juste d'une URL vers une image.
        {
            id: 'meiko',
            asset: 'assets/meiko-face.svg',
            // ...
        },

        {
            id: 'mozza',
            asset: 'assets/pot-mozzarella.svg',
            // ...
        },
        {
            id: 'tomato',
            asset: 'assets/sauce-tomate.svg',
            // ...
        },
        {
            id: 'dough',
            asset: 'assets/pate-pizza-boule.png',
            // ...
        },

        {
            id: 'oven',
            asset: 'assets/four.svg',
            // ...
        },
        {
            id: 'hatch',
            asset: 'assets/passeplat.svg',
            // ...
        },
        {
            id: 'fridge',
            asset: 'assets/refrigirateur.svg',
            // ...
        },
        {
            id: 'workplan',
            asset: 'assets/plan-travail.svg',
            // ...
        },
        {
            id: 'shelf',
            asset: 'assets/etagere-horizontale.svg',
            // ...
        },
    ];

    // ...
</script>

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

    /*
        On vire quasiment tout le CSS concernant les entités (sauf les z-index)
        et on le remplace par... seulement ce max-width pour éviter que les
        images débordent des cases. Vous vous attendiez à plus de CSS ? :)
    */
    .board-cell-entity img {
        max-width: 100%;
    }

    /* Bon OK, on en ajoute un pour que la mozza ne déborde pas trop du frigo */
    .board-cell-entity.mozza img {
        max-width: 85%;
    }

    /* ... */
</style>

Et voilà, en seulement quelques lignes, on a largement transformé notre jeu. On est désormais visuellement très proches du jeu d’origine. Un petit détail toutefois reste à régler : on aimerait bien que Meiko se tourne dans la direction vers laquelle on vient de cliquer. Pour cela on va avoir besoin de 4 images représentant Meiko : une pour chaque direction. La question qui se pose est : « comment changer d’image ? »

Je vais aborder ici deux solutions possibles pour faire cela. Le choix que j’ai fait n’est pas forcément le meilleur, mais il sera justifié. Libre à vous de penser que c’est vraiment n’importe quoi et de préférer l’autre solution.

La première façon de faire est de regrouper toutes les images au sein d’un seul fichier : un « sprite » (ou lutin, c’est Wikipédia qui le dit, j’ai toujours entendu parler que de sprite). Ensuite, il faut afficher seulement une partie de l’image, en fonction d’une classe CSS par exemple. Je ne rentre pas dans le détail de l’implémentation, les méthodes sont de plus sensiblement différentes si vous avez un PNG ou un SVG. Cette technique me posait plusieurs problèmes :

Bref, je suis parti sur une seconde solution plus naïve, moins performante et moins élégante, mais qui me permettait au moins d’avancer sans me poser trop de questions.

La technique est simple. Chaque image se trouve dans un fichier à part, et on les rattache via un composant assets (notez le « s » final, c’est un autre composant !) qui va regrouper ces images, indexées par des directions. Lors de l’affichage, on va calculer dynamiquement l’image à afficher en fonction de la direction vers laquelle Meiko est dirigé. Ça ressemble à ça :

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

        <div
          v-for="entity in store"
          :key="entity.id"
          :class="['board-cell-entity', entity.id]"
          :style="entityPosition(entity)"
        >
            <!-- On affiche une image si l'entité possède un composant "asset" -->
            <img
                v-if="entity.asset"
                :src="entity.asset"
                alt=""
                class="entity-asset"
            />

            <!--
                Ou bien un composant "assets", il faut alors calculer
                dynamiquement l'image à afficher.
            -->
            <img
                v-else-if="entity.assets"
                :src="entityAssetImage(entity)"
                alt=""
                class="entity-asset"
            />

            <!-- Sinon, on continue d'afficher le label -->
            <div v-else-if="entity.label" class="entity-label">
                {{ entity.label }}
            </div>
        </div>

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

<script>
    // ...

    let gameStore = [
        {
            id: 'meiko',

            // Pour Meiko, on remplace le composant "asset" par "assets". Cela
            // nous permettra d'associer plusieurs images, la bonne sera
            // choisie dynamiquement lors de l'affichage.
            assets: {
                bottom: 'assets/meiko-face.svg',
                right: 'assets/meiko-droite.svg',
                left: 'assets/meiko-gauche.svg',
                top: 'assets/meiko-dos.svg',
            },

            // On ajoute également un composant "direction" à Meiko
            direction: 'bottom',

            // ...
        },

        // ...
    ];

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

        methods: {
            // ...

            // Cette méthode JS calcule dynamiquement l'image à afficher pour
            // les entités possédant un composant "assets"
            entityAssetImage(entity) {
                if (entity.assets == null || entity.direction == null) {
                    return '';
                }

                // Comme le composant "assets" est indexé par les directions,
                // récupérer la bonne image se fait très facilement. Ce code
                // n’est pas très robuste mais comme le jeu est très simple
                // avec peu de contributeurices, on peut s’en contenter pour
                // l'instant.
                return entity.assets[entity.direction];
            },
        },
    });

    // ...
</script>

La dernière étape consiste à changer effectivement la direction de Meiko en fonction d’où le clic a été effectué. Vous l’aurez compris : on va modifier l’état du store, cela se passe donc au sein de notre système onClickSystem.

function onClickSystem(e, store) {
    // ...

    // Notre code doit s'exécuter avoir vérifié que Meiko peut accéder à la
    // case ciblée, sinon il ne doit pas bouger du tout.
    const meiko = getEntity(store, 'meiko');
    if (!positionIsAccessibleFor(meiko, position)) {
        return store;
    }

    // On veut désormais changer la direction de Meiko : il suffit de
    // comparer sa position à la position de la case ciblée pour savoir
    // vers où Meiko doit se tourner.
    let direction = meiko.direction;
    if (position.x > meiko.position.x) { direction = 'right'; }
    else if (position.x < meiko.position.x) { direction = 'left'; }
    else if (position.y < meiko.position.y) { direction = 'top'; }
    else if (position.y > meiko.position.y) { direction = 'bottom'; }

    let updatedStore = setEntityComponents(store, meiko.id, { direction });

    // On fait bien attention à utiliser le updatedStore à partir d’ici au
    // lieu du store sinon Meiko ne se tournera pas.
    const entitiesAtPosition = searchEntitiesAt(updatedStore, position);
    if (entitiesAtPosition.some((entity) => entity.obstruct)) {
        return updatedStore;
    }

    updatedStore = setEntityComponents(updatedStore, meiko.id, { position });

    return updatedStore;
}

Avec ça on a fini notre coup de peinture sur le jeu. D’ailleurs, on a même terminé de toucher au HTML concernant la zone de jeu elle-même : même pas 50 lignes de code. Vous remarquerez sans doute en testant le jeu (le résultat est ici) que Meiko ne change pas immédiatement de direction : il s’agit du temps de charger l’image la première fois. On corrigera ça plus tard dans un article bonus. Pour l’instant, on a quelque chose de bien plus important à faire : ajouter les étapes ainsi que les actions pour avancer dans le jeu. Ce sera l’objet des deux prochains articles (au moins !)

Une histoire de pizzas : contraindre les mouvements

mercredi 6 novembre 2019 à 09:00

Bienvenue sur le déjà quatrième article retraçant le développement d’un mini-jeu web que j’ai développé pour les Ergogames. Les introductions se suivent et se ressemblent toutes alors commencez par lire les autres articles de la série avant d’aller plus loin si vous voulez comprendre quelque chose.

Bien, on a donc vu dans l’épisode précédent comment mouvoir le petit corps de Meiko. Le problème qui se posait alors est qu’il pouvait se déplacer n’importe où sur le terrain de jeu. Je vous propose donc de voir comment le forcer à se déplacer de case en case, sans monter sur le frigo ni l’étagère (c’est dangereux !!)

Pour ce faire, on touchera uniquement au JavaScript cette fois-ci : tout se passera dans notre système onClickSystem, celui qui prend un clic en entrée et modifie la position de Meiko. On commence par vérifier que le clic a bien été fait sur une case du jeu :

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;

    const position = { x, y };

    // On vérifie que la position visée correspond bien à une case du jeu.
    // En théorie c'est toujours le cas (souvenez-vous la modification
    // faite en CSS dans l'article précédent), mais c'est bien de le
    // vérifier en JS aussi.
    if (
        position.x < 0 ||
        position.x >= BOARD_SIZE ||
        position.y < 0 ||
        position.y >= BOARD_SIZE
    ) {
        return store;
    }

    // ...
}

Si le clic est en dehors du terrain, on retourne le store tel quel, pas besoin de faire autre chose.

La seconde étape est de vérifier que Meiko a effectivement accès à la case sur laquelle on vient de cliquer. Il faut pour cela qu’il s’agisse d’une case adjacente. Avant de vous montrer le code, il me faut expliquer un peu comment calculer ça.

Pour une entité donnée, il existe un maximum de 4 cases accessibles (j’exclue la case sur laquelle se trouve l’entité, mais un autre choix peut être pertinent). Pour faciliter la représentation, prenez le tableau de 3x3 cases suivant (l’entité est représentée par e et les cases accessibles par les o) :

   0 1 2
0 | |o| |
1 |o|e|o|
2 | |o| |

Notre entité est donc en position x=1, y=1. Si l’on fait la différence de son abscisse avec n’importe quelle abscisse des cases accessibles, la valeur absolue est égale soit à 0, soit à 1 (1 - 0 = 1 ; 1 - 1 = 0 ; 1 - 2 = -1). Le résultat est le même pour les ordonnées, mais on remarque que lorsque la différence des abscisses est égale à 1, la différence des ordonnées est égale à 0 (et inversement). Le cas où les deux différences sont égales à 0 correspond à la case où se trouve l’entité, et les cas où les deux sont égales à 1 correspondent aux cases en diagonales.

Pour savoir si Meiko a le droit d’accéder à la case ciblée, il nous faut donc récupérer les deux positions, faire leur différence en valeur absolue en x et en y et vérifier que la somme fait 1.

function onClickSystem(e, store) {
    // ...

    // On récupère notre entité Meiko. On a besoin de connaître sa position
    // pour vérifier que la case visée est accessible.
    const meiko = getEntity(store, 'meiko');

    // On teste que la position est bien accessible à Meiko grâce à une
    // fonction utilitaire. Si elle ne l'est pas, on retourne le store tel
    // quel.
    if (!positionIsAccessibleFor(meiko, position)) {
        return store;
    }

    // ...
}

function getEntity(store, id) {
    // Ici c'est un bête appel à une fonction JS (find) pour trouver notre
    // entité dans le store (qui est un tableau). Le `|| null` permet de
    // retourner null au lieu de undefined, simple coquetterie de ma part.
    return store.find((entity) => entity.id === id) || null;
}

function positionIsAccessibleFor(entity, position) {
    // Si l'entité passée en paramètre n'a pas de position, elle va
    // forcément pas avoir accès à la position donnée puisqu'elle n'est pas
    // présente dans notre tableau !
    if (entity.position == null) {
        return false;
    }

    // On fait la différence (en valeur absolue) de nos abscisses...
    const diff_x = Math.abs(entity.position.x - position.x);
    // ... de nos ordonnées...
    const diff_y = Math.abs(entity.position.y - position.y);
    // ... et on vérifie que seulement l'une des 2 différences est égale
    // à 1. On aurait aussi pu écrire ça `diff_x + diff_y === 1`, mais je
    // trouve ça un peu moins clair.
    return (diff_x === 1 && diff_y === 0) || (diff_x === 0 && diff_y === 1);
}

Avec ça, Meiko arrête de voler à travers le terrain et est obligé de se déplacer sur une case adjacente. Il reste encore à régler sa manie de monter sur tous les objets de la cuisine. On va faire ça en deux temps.

La première chose à faire est d’ajouter un composant à nos entités stockées dans le store pour indiquer qu’elles ne peuvent pas être franchies. J’ai décidé de nommer celui-ci obstruct : si sa valeur est true, alors l’entité ne peut pas être franchie.

let gameStore = [
    // ...

    {
        id: 'meiko',
        label: 'Meiko',
        size: { width: 1, height: 1 },
        position: { x: 3, y: 2 },
        obstruct: true,
    },

    {
        id: 'oven',
        label: 'Four',
        size: { width: 2, height: 1 },
        position: { x: 1, y: 0 },
        obstruct: true,
    },
    {
        id: 'hatch',
        label: 'Passe-plat',
        size: { width: 1, height: 1 },
        position: { x: 5, y: 0 },
        obstruct: true,
    },
    {
        id: 'fridge',
        label: 'Frigo',
        size: { width: 2, height: 2 },
        position: { x: 0, y: 2 },
        obstruct: true,
    },
    {
        id: 'workplan',
        label: 'Plan de travail',
        size: { width: 1, height: 2 },
        position: { x: 5, y: 2 },
        obstruct: true,
    },
    {
        id: 'shelf',
        label: 'Étagère',
        size: { width: 4, height: 1 },
        position: { x: 2, y: 5 },
        obstruct: true,
    },
];

Ici j’ai déclaré le composant uniquement sur les gros objets ainsi que sur Meiko. Si par exemple la sauce tomate traîne au milieu de la cuisine, Meiko pourra passer au-dessus car je ne lui ai pas ajouté obstruct: true.

La deuxième étape est maintenant de récupérer les entités présentes sur la case visée et de vérifier qu’aucune n’est bloquante.

Avant de rentrer dans le code, une fois n’est pas coutume, on va décortiquer un peu ce qu’on veut faire. En théorie, trouver les entités présentes sur une case est facile : il suffit de sélectionner celles dont la position est égale à celle de la case (c’est-à-dire lorsque les valeurs x et y sont identiques). Mais vous avez sans doute remarqué que nos entités ont une taille qui n’est pas toujours de 1x1 case ? La position d’une entité correspond en fait à son angle haut-gauche. On a donc besoin de récupérer l’ensemble des positions occupées par notre entité, et vérifier qu’au moins une correspond à la case visée. Pour cela il faut faire un petit calcul. Prenons l’entité suivante qui occupe quatre cases :

   0 1
0 |e|e|
1 |e|e|

Sa position est x=0, y=0, sa largeur ainsi que sa hauteur sont de 2 chacune. On veut récupérer les cases suivantes à partir de ces deux informations : x=0, y=0, x=1, y=0, x=0, y=1 et x=1, y=1. Pour cela, il suffit d’itérer sur l’intervalle entre 0 et la largeur exclue (ici, « 2 exclu » est égal à 1) : chaque valeur prise est ajoutée au x de la position initiale et équivaut à une position prise par l’entité. On fait évidemment la même chose avec la hauteur et y.

Ce n’est pas très simple à expliquer avec des mots, donc voici le code de cette dernière partie :

function onClickSystem(e, store) {
    // ...

    // On récupère les entités présentes à la position donnée pour vérifier
    // qu'aucune n'est bloquante et empêche ainsi le déplacement.
    const entitiesAtPosition = searchEntitiesAt(store, position);

    // `some` retournera `true` si au moins une entité possède `obstruct` à
    // `true`.
    if (entitiesAtPosition.some((entity) => entity.obstruct)) {
        return store;
    }

    // Et enfin, on met à jour la position de Meiko dans le store. Le seul
    // changement par rapport à la dernière fois est qu'on utilise
    // `meiko.id` au lieu de `'meiko'`.
    let updatedStore = setEntityComponents(store, meiko.id, { position });

    return updatedStore;
}

function searchEntitiesAt(store, position) {
    // On filtre ici les entités présentes à la position donnée en
    // argument. La difficulté réside dans le fait qu'une entité à une
    // taille et peut donc être présente sur plusieurs positions à la fois.
    return store.filter((entity) => {
        if (entity.position == null) {
            // Pas de position, pas de chocolat
            return false;
        }

        if (entity.size == null) {
            // si l'entité n'a pas de taille, on considère quand même
            // qu'elle a une présence de 1x1. On a juste à tester que les
            // positions sont égales. En pratique dans ce jeu, ce cas de
            // figure ne se présente pas.
            return (
                entity.position.x === position.x &&
                entity.position.y === position.y
            );
        }

        // getCoveredPositionsBy retourne l'ensemble des positions que
        // l'entité occupe, on vérifie juste que l'une de ces positions
        // correspond à la position recherchée.
        return getCoveredPositionsBy(entity).some((coveredPosition) => (
            coveredPosition.x === position.x &&
            coveredPosition.y === position.y
        ));
    });
}

function getCoveredPositionsBy(entity) {
    // Ici on collectionne les ~canards~ les positions prises par l’entité.
    const coveredPositions = [];

    // On itère sur l'intervalle entre 0 et la largeur exclue (ouais bon,
    // le code JS est pas hyper accueillant)
    for (const w of Array(entity.size.width).keys()) {
        // Puis sur l'intervalle entre 0 et la hauteur exclue
        for (const h of Array(entity.size.height).keys()) {
            // On a plus qu’à additionner les valeurs à x et y pour obtenir
            // une position occupée par l'entité
            coveredPositions.push({
                x: entity.position.x + w,
                y: entity.position.y + h,
            });
        }
    }
    return coveredPositions;
}

Et voilà ! Vous pouvez tester (le résultat final est ici), Meiko ne peut plus monter sur le frigo, ni sur le plan de travail. Si vous décidez de tripatouiller un peu le code, vous pourrez vérifier que Meiko peut cependant allègrement marcher sur la Mozzarella si on la laisse trainer au milieu de la cuisine (ce n’est pas le cas dans le jeu initial, mais ça me permettait de mieux illustrer l’utilité des composants).

Bien qu’il n’y ait pas eu « beaucoup » de code dans cet article, celui-ci était sans doute assez dense pour la compréhension. Le prochain devrait traiter d’un aspect un peu plus sympa : l’ajout des images. On pourra ainsi se débarrasser de nos blocs moches dessinés à l’arrache.

Une histoire de pizzas : les déplacements

vendredi 1 novembre 2019 à 20:30

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 ! 😄

Une histoire de pizzas : les objets

lundi 28 octobre 2019 à 17:30

J’ai commencé la semaine dernière une série d’articles pour expliquer comment j’ai développé un jeu pour les Ergogames. Il me semble qu’il n’y a rien d’exceptionnel dans ce que j’explique, je vise plutôt un public amateur qui chercherait à comprendre comment fonctionne un jeu. Mon but est aussi de montrer ma démarche alors que je n’y connaissais rien au développement de jeux vidéos (enfin « presque rien », j’y reviens plus loin).

Dans l’article précédent nous avons commencé par afficher une grille de 6 par 6 cases. Nous allons maintenant afficher les éléments du jeu (le frigo, le four, Meiko, etc.) Ces éléments vont interagir entre eux, il faut donc les représenter dans notre code JavaScript pour gérer ces aspects « dynamiques » et l’« état » du jeu. Cette représentation est importante et ne doit pas être prise à la légère puisqu’elle va impliquer notre capacité à ajouter de nouvelles fonctionnalités au jeu.

C’est le moment où je révèle que j’ai légèrement triché dans mon précédent article quand je disais que je n’y connaissais rien : je m’étais déjà un peu amusé par le passé avec quelques concepts, et notamment l’architecture « entité-composant-système ». J’avais découvert ce paradigme dans une série d’articles sur LinuxFR et j’avais même commencé à développé une bibliothèque Python, Pytity.

Le principe est assez simple :

Alors comment on s’y prend pour représenter tout ça ? Ici on va faire simple et même légèrement différent de ce que j’ai fait pour les Ergogames afin d’être plus lisible. Il n’existe de toute façon pas de manière unique pour représenter une architecture « entité-composant-système ».

Commençons par lister les différents objets de notre jeu à l’état initial, nous en avons 9 :

// L'ensemble de nos entités avec leurs composants est stocké dans un gros
// objet JavaScript.
let gameStore = {
    // L'identifiant de l'entité est une clé (meiko) de l'objet JS. On lui
    // associe un ensemble de composants (label, size et position)
    meiko: {
        label: 'Meiko',
        size: { width: 1, height: 1 },
        position: { x: 3, y: 2 },
    },

    // Et on fait la même chose avec toutes les entités de notre jeu
    mozza: {
        label: 'Mozzarella',
        size: { width: 1, height: 1 },
        position: { x: 1, y: 3 },
    },
    tomato: {
        label: 'Sauce tomate',
        size: { width: 1, height: 1 },
        position: { x: 2, y: 5 },
    },
    dough: {
        label: 'Pâte à pizza',
        size: { width: 1, height: 1 },
        position: { x: 5, y: 3 },
    },

    oven: {
        label: 'Four',
        size: { width: 2, height: 1 },
        position: { x: 1, y: 0 },
    },
    hatch: {
        label: 'Passe-plat',
        size: { width: 1, height: 1 },
        position: { x: 5, y: 0 },
    },
    fridge: {
        label: 'Frigo',
        size: { width: 2, height: 2 },
        position: { x: 0, y: 2 },
    },
    workplan: {
        label: 'Plan de travail',
        size: { width: 1, height: 2 },
        position: { x: 5, y: 2 },
    },
    shelf: {
        label: 'Étagère',
        size: { width: 4, height: 1 },
        position: { x: 2, y: 5 },
    },
};

const app = new Vue({
    el: '#app',
    data: {
        size: BOARD_SIZE,
        // on ajoute le store à "data" pour pouvoir y accéder dans le html
        store: gameStore,
    },
});

Ceci étant fait, nous pouvons créer notre premier « système » : celui qui va afficher les objets. Dans notre cas, le rendu est géré par le navigateur ce qui nous simplifie grandement la vie par rapport à un jeu « natif ». Ça n’a d’ailleurs pas grand-chose à voir à mon avis et je ne sais pas si on peut vraiment parler de « système ». On va se contenter ici d’écrire un peu de HTML et de JS (pour positionner les entités au bon endroit) :

<div class="board">
    <!-- Ça, ça ne change pas -->
    <div v-for="y in size" :key="y" class="board-line">
        <div v-for="x in size" :key="x" class="board-cell">
        </div>
    </div>

    <!--
        On itère sur les entités (objets) du store pour les ajouter dans le
        HTML. Le truc important ici est la fonction "entityPosition" qui va
        retourner les dimensions et la position en CSS de l'entité pour que
        le navigateur l'affiche au bon endroit.
    -->
    <div
      v-for="(entity, id) in store"
      :key="id"
      :class="['board-cell-entity', id]"
      :style="entityPosition(entity)"
    >
        <!-- Si l'entité a un composant "label", on l’ajoute au HTML. -->
        <div v-if="entity.label" class="entity-label">
            {{ entity.label }}
        </div>
    </div>
</div>

<script>
    // ...

    // Cette valeur correspond à la variable --cell-size en CSS.
    const CSS_CELL_SIZE = 128;

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

        methods: {
            // entityPosition retourne la position de l'entité en CSS.
            entityPosition(entity) {
                if (entity.size == null || entity.position == null) {
                    // Si l'entité ne possède pas de taille ou de position,
                    // cela signifie qu'on ne peut pas l'afficher : on ne
                    // retourne donc rien.
                    return {};
                }

                return {
                    // on multiplie la taille de l'entité par la taille d'une
                    // cellule pour obtenir sa taille en CSS.
                    width: (entity.size.width * CSS_CELL_SIZE) + 'px',
                    height: (entity.size.height * CSS_CELL_SIZE) + 'px',

                    // et pareil pour obtenir sa position
                    top: (entity.position.y * CSS_CELL_SIZE) + 'px',
                    left: (entity.position.x * CSS_CELL_SIZE) + 'px',
                };
            },
        },
    });
</script>

Une solution plus élégante serait de ne générer le HTML que pour les entités avec une taille et une position : en effet, quel intérêt y a-t-il à ajouter une entité qui n’a pas d’existence visuelle ? J’ai choisi cette façon de faire afin de garder le code plus simple et parce que je n’avais pas pensé à cela initialement (je reste ainsi plus proche de ce que j’ai fait initialement).

Vous noterez aussi que je ne tente pas de placer les entités dans les cellules du tableau que l’on a généré lors de l’étape précédente. En effet, une entité peut se trouver dans plusieurs cellules en même temps et ce serait galère à gérer correctement en termes de HTML. Je préfère donc dissocier les entités des cellules et les superposer. Ça n’a pas beaucoup d’importance ici puisqu’il s’agit uniquement de rendu visuel.

À ce niveau on est pas mal, mais ça reste toujours moche et on ne voit pas grand-chose. On va donc ajouter du CSS pour rendre nos entités correctement.

/* ... */

/*
   Ça c'est important pour pas se prendre la tête sur les dimensions mais
   je ne rentre pas dans le détail du pourquoi du comment.
*/
* {
    box-sizing: border-box;
}

/*
   Les entités ont une position absolue *au sein* du tableau, donc on
   précise le nécessaire pour éviter qu'elles ne volent n'importe où à
   l'écran, et surtout pas en dehors du cadre de jeu.
*/
.board {
    position: relative;
}
.board-cell-entity {
    position: absolute;
}

/* Puis on colorie nos entités pour les rendre plus visibles à l'écran */
.board-cell-entity.oven { background-color: #333; }
.board-cell-entity.fridge { background-color: #666; }
.board-cell-entity.workplan { background-color: #783f04; }
.board-cell-entity.shelf { background-color: #000; }
.board-cell-entity.hatch { background-color: #cf2a27; }

.board-cell-entity .entity-label { color: #fff; }
.board-cell-entity.shelf .entity-label { text-align: right; }

/*
   Certaines entités peuvent être superposées à d'autres (la pâte, la mozza
   et la sauce tomate). On leur met un z-index pour être sûr qu'elles se
   trouveront au-dessus.
*/
.board-cell-entity.dough,
.board-cell-entity.mozza,
.board-cell-entity.tomato {
    z-index: 10;

    background-color: green;
}

/*
   Meiko a droit à un style un peu différent mais ça change pas grand-chose
   à la logique : on le colorie aussi !
*/
.board-cell-entity.meiko {
    border: 2px solid green;
}
.board-cell-entity.meiko .entity-label {
    color: initial;
}

Notez ici que la majorité du code CSS n’est pas très important puisqu’on remplacera nos blocs colorés par des images plus tard.

On arrive toutefois à la fin de cet article puisqu’on a vu comment représenter les objets du jeu et comment les afficher. Le résultat final est visible ici et, comme la dernière fois, n’hésitez pas à regarder le code source de la page.

Nous verrons dans le prochain article comment déplacer Meiko de case en case tout en faisant attention à ce qu’il ne puisse pas traverser les objets tel que le frigo.

Une histoire de pizzas : la grille

jeudi 24 octobre 2019 à 13:30

En début d’année, j’ai passé du temps à développer un mini-jeu pour les Ergogames. Le principe des Ergogames est simple : faire découvrir des principes d’ergonomie à travers des jeux qui illustrent différents concepts. Ceux-ci sont conçus par deux anciennes collègues ergonomes, Marion et Margaux, via l’agence de design uxShadow de Sogilis (mon ancienne boite).

Elles m’ont demandé si je pouvais leur donner un coup de main et j’ai accepté à condition que le code soit ouvert (ça se passe ici 😊).

J’ai donc développé le mini-jeu « À vous les pizzas ! » en précisant bien que je n’avais jamais conçu de jeux mais que c’est pas grave, « je vais apprendre ». Du coup j’ai effectivement appris, et c’est ce cheminement que j’aimerais partager maintenant à travers une série d’articles. Je préviens tout de suite : je n’ai aucune idée de si je finirai cette série, mais enfin, je l’aurai au moins commencée. Si vous cherchez le code du jeu en lui-même, il se trouve ici.

Le but de la série va être de redévelopper le jeu pas à pas. Je n’irai pas aussi loin que celui des Ergogames pour me simplifier la vie, mais nous verrons quand même les principaux mécanismes. Le principe du jeu est de déplacer un personnage (Meiko) dans une cuisine en cliquant de case en case pour lui faire fabriquer une pizza. Je vous laisse jouer au jeu sur le site des Ergogames pour découvrir le principe d’ergonomie qui est illustré ici. Le résultat ressemble à cela :

Capture d’écran du jeu montrant Meiko au milieu d’une cuisine

Le jeu sera développé en HTML, CSS et JavaScript avec la librairie VueJS afin de rester le plus proche possible de ce qui a été fait initialement. J’éviterai tout de même toute étape de build qui consisterait à transformer le code JS en un seul fichier optimisé : c’est ce qui est fait pour les Ergogames mais ça complexifierait la compréhension sans rien apporter de plus. Je vais également m’éviter quelques bonnes pratiques en mixant allègrement tout le code (HTML, JS et CSS) au sein d’un même fichier (ne faites pas ça chez vous).

Dans ce premier article qui servait essentiellement à introduire la série, on va se contenter d’afficher un cadre de 6 par 6 cases. Comme je l’ai dit plus haut, je n’avais encore jamais développé de jeu, donc j’ai avancé de manière très naïve. Il me semblait évident que la première phase consisterait à dessiner le tableau de jeu.

Cela permet en plus d’introduire à la fois un peu de HTML, de CSS et de JS et donc de poser les bases. Pour représenter le cadre, on va se contenter de 6 balises <div> pour les lignes, découpées elles-mêmes en 6 autres balises <div> pour les cellules. On va s’éviter tout de suite de copier-coller plein de code HTML et on va donc ajouter VueJS immédiatement et répéter ces blocs avec des boucles v-for. Ça donne quelque chose que j’espère simple :

<div id="app">
    <div class="board">
        <!--
            la variable size est passée par VueJS et a pour valeur 6. On répète
            donc 6 fois la div "board-line", au sein desquelles on répète 6
            fois la div "board-cell".
        -->
        <div v-for="y in size" :key="y" class="board-line">
            <div v-for="x in size" :key="x" class="board-cell">
            </div>
        </div>
    </div>
</div>

<!--
    J’ajoute la librairie VueJS via un CDN, mais dans la vraie vie préférez
    héberger le code vous-même, c’est meilleur pour la vie privée de vos
    visiteurs et visiteuses.
-->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

<script>
    const BOARD_SIZE = 6;

    // Un tout petit peu de JS pour dire à VueJS de "monter" l'application sur
    // la balise possédant l'id #app et passer la variable size. Je ne rentre
    // pas dans le détail ici, ce n’est pas très important.
    const app = new Vue({
        el: '#app',
        data: {
            size: BOARD_SIZE,
        },
    });
</script>

Avec ça, on a la base de notre code HTML, par contre ça ne ressemble à rien. On va donc ajouter un zeste de CSS pour rendre le tout visuel :

/*
    Ici on définit quelques variables CSS, c'est pas forcé mais ça aidera à
    la maintenance du code.
*/
#app {
    --cell-size: 128px;

    --color-floor: #fff;
    --color-floor-border: #ccc;
}

/*
    Une ligne est un conteneur flex, ça permet de se simplifier la vie pour
    aligner les cellules. La conséquence ici est que les .board-cell seront
    affichées en lignes et non pas en colonnes.
*/
.board-line {
    display: flex;
}

/*
    Puis on définit la taille des cellules ainsi qu’une couleur de fond et
    de bordure pour les mettre en évidence.
*/
.board-cell {
    width: var(--cell-size);
    height: var(--cell-size);

    background-color: var(--color-floor);
    border: 1px solid var(--color-floor-border);
}

Avec ça on a un cadre que l’on va pouvoir améliorer par petites touches pour arriver au jeu final. Le résultat final est visible ici (n’hésitez pas à ouvrir la console de votre navigateur pour regarder le code).

J’ai essayé ici de détailler au mieux ce que je faisais pour faciliter la compréhension du code afin de rendre ma série d’articles accessible au plus de monde possible, en particulier aux débutant·es. N’hésitez pas à me contacter si des choses ne sont pas claires !

Le prochain article explique comment afficher les éléments dans le jeu.