Commit afbfd08f authored by berumuron's avatar berumuron
Browse files

Ajoute le 5e article des pizzas

parent bade6a6b
---
title: Une histoire de pizzas : coup de pinceau
date: 2019-11-11 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](serie/ergogames.html) retraçant la
conception d’un mini-jeu vidéo !
Dans [le deuxième article](une-histoire-de-pizzas-2.html), 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](https://twitter.com/UxShadow)
ont fait appel à [Goulven Barron](https://twitter.com/GoulvenBARON) 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](https://gitlab.com/sogilis/ergogames.fr/tree/master/src/assets/img/pizzas)
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’<abbr>URL</abbr> vers l’image et
ajouter cette dernière au <abbr>HTML</abbr> à l’aide d’une balise `<img />`.
```html
<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](https://fr.wikipedia.org/wiki/Sprite_(jeu_vid%C3%A9o)),
j’ai toujours entendu parler que de _sprite_). Ensuite, il faut afficher
seulement une partie de l’image, en fonction d’une classe <abbr>CSS</abbr> 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 <abbr>PNG</abbr> ou un
<abbr>SVG</abbr>. Cette technique me posait plusieurs problèmes :
- générer un _sprite_ est un poil plus pénible (soit on complique la tâche de
la personne qui génère l’image, soit on complique la chaîne de _build_ du
projet)
- je ne savais pas faire avec des <abbr>SVG</abbr> et n’avais pas envie de
passer trop de temps là-dessus, ayant des choses plus intéressantes à
développer par ailleurs (c’est important d’utiliser son temps à bon
escient 😉)
- je pensais que ça me poserait des problèmes de _responsive design_ (problèmes
grandement exagérés par le fait que je ne savais pas faire, tout est lié !)
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 :
```html
<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`.
```javascript
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 <abbr>HTML</abbr> 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](une-histoire-de-pizzas/etape5.html)) 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 !)
</
---
title: Une histoire de pizzas, étape 5
template: game
---
<a href="../une-histoire-de-pizzas-5.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)"
>
<!-- 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>
<a href="#" @click="onBoardClick" class="board-click-zone"></a>
</div>
</div>
<script>
const BOARD_SIZE = 6;
const CSS_CELL_SIZE = 128;
let gameStore = [
// On se contente d'ajouter un composant "asset" ou "assets" à chacune
// de nos entités. Il s’agit juste d'une URL vers une image.
// "assets" permet d'associer plusieurs images, la bonne sera choisie
// dynamiquement lors de l'affichage.
{
id: 'meiko',
label: 'Meiko',
assets: {
bottom: 'assets/meiko-face.svg',
right: 'assets/meiko-droite.svg',
left: 'assets/meiko-gauche.svg',
top: 'assets/meiko-dos.svg',
},
size: { width: 1, height: 1 },
position: { x: 3, y: 2 },
// On ajoute également un composant "direction" à Meiko
direction: 'bottom',
obstruct: true,
},
{
id: 'mozza',
label: 'Mozzarella',
asset: 'assets/pot-mozzarella.svg',
size: { width: 1, height: 1 },
position: { x: 1, y: 3 },
},
{
id: 'tomato',
label: 'Sauce tomate',
asset: 'assets/sauce-tomate.svg',
size: { width: 1, height: 1 },
position: { x: 2, y: 5 },
},
{
id: 'dough',
label: 'Pâte à pizza',
asset: 'assets/pate-pizza-boule.png',
size: { width: 1, height: 1 },
position: { x: 5, y: 3 },
},
{
id: 'oven',
label: 'Four',
asset: 'assets/four.svg',
size: { width: 2, height: 1 },
position: { x: 1, y: 0 },
obstruct: true,
},
{
id: 'hatch',
label: 'Passe-plat',
asset: 'assets/passeplat.svg',
size: { width: 1, height: 1 },
position: { x: 5, y: 0 },
obstruct: true,
},
{
id: 'fridge',
label: 'Frigo',
asset: 'assets/refrigirateur.svg',
size: { width: 2, height: 2 },
position: { x: 0, y: 2 },
obstruct: true,
},
{
id: 'workplan',
label: 'Plan de travail',
asset: 'assets/plan-travail.svg',
size: { width: 1, height: 2 },
position: { x: 5, y: 2 },
obstruct: true,
},
{
id: 'shelf',
label: 'Étagère',
asset: 'assets/etagere-horizontale.svg',
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',
};
},
// 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];
},
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 };
if (
position.x < 0 ||
position.x >= BOARD_SIZE ||
position.y < 0 ||
position.y >= BOARD_SIZE
) {
return 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;
}
function getEntity(store, id) {
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) {
if (entity.position == null) {
return false;
}
const diff_x = Math.abs(entity.position.x - position.x);
const diff_y = Math.abs(entity.position.y - position.y);
return (diff_x === 1 && diff_y === 0) || (diff_x === 0 && diff_y === 1);
}
function searchEntitiesAt(store, position) {
return store.filter((entity) => {
if (entity.position == null) {
return false;
}
if (entity.size == null) {
return (
entity.position.x === position.x &&
entity.position.y === position.y
);
}
return getCoveredPositionsBy(entity).some((coveredPosition) => (
coveredPosition.x === position.x &&
coveredPosition.y === position.y
));
});
}
function getCoveredPositionsBy(entity) {
const coveredPositions = [];
for (const w of Array(entity.size.width).keys()) {
for (const h of Array(entity.size.height).keys()) {
coveredPositions.push({
x: entity.position.x + w,
y: entity.position.y + h,
});
}
}
return coveredPositions;
}
</script>
<style type="text/css">