Une histoire de pizzas : contraindre les mouvements

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.

Revenir à la série