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