Commit 0baa7bfa authored by berumuron's avatar berumuron
Browse files

Ajoute le 4e article des pizzas

parent cc6bca92
---
title: Une histoire de pizzas : contraindre les mouvements
date: 2019-11-06 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](https://ergogames.fr). Les
introductions se suivent et se ressemblent toutes alors commencez par lire [les
autres articles de la série](serie/ergogames.html) avant d’aller plus loin si
vous voulez comprendre quelque chose.
Bien, on a donc vu dans [l’épisode précédent](une-histoire-de-pizzas-3.html)
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 :
```javascript
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.
```javascript
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.
```javascript
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 :
```javascript
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](une-histoire-de-pizzas/etape4.html)),
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.
---
title: Une histoire de pizzas, étape 4
template: game
---
<a href="../une-histoire-de-pizzas-4.html">Lire l’article associé</a>
<div id="app">
<div class="board">
<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
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>
<a href="#" @click="onBoardClick" class="board-click-zone"></a>
</div>
</div>
<script>
const BOARD_SIZE = 6;
const CSS_CELL_SIZE = 128;
let gameStore = [
{
id: 'meiko',
label: 'Meiko',
size: { width: 1, height: 1 },
position: { x: 3, y: 2 },
obstruct: true,
},
{
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 },
},
{
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,
},
];
const app = new Vue({
el: '#app',
data: {
size: BOARD_SIZE,
store: gameStore,
},
methods: {
entityPosition(entity) {
if (entity.size == null || entity.position == null) {
return {};
}
return {
width: (entity.size.width * CSS_CELL_SIZE) + 'px',
height: (entity.size.height * CSS_CELL_SIZE) + 'px',
top: (entity.position.y * CSS_CELL_SIZE) + 'px',
left: (entity.position.x * CSS_CELL_SIZE) + 'px',
};
},
onBoardClick(e) {
this.store = onClickSystem(e, this.store);
},
},
});
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;
}
// 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;
}
// 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 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 setEntityComponents(store, id, components) {
return store.map((entity) => {
if (entity.id !== id) {
return entity;
}
return {
...entity,
...components,
};
});
}
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);
}
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;
}
</script>
<style type="text/css">
#app {
--board-size: 6;
--cell-size: 128px;
--color-floor: #fff;
--color-floor-border: #ccc;
}
* {
box-sizing: border-box;
}
.board-line {
display: flex;
}
.board-cell {
width: var(--cell-size);
height: var(--cell-size);
background-color: var(--color-floor);
border: 1px solid var(--color-floor-border);
}
.board {
position: relative;
width: calc(var(--board-size) * var(--cell-size));
}
.board-cell-entity {
position: absolute;
}
.board-click-zone {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;