Commit 2937439c authored by berumuron's avatar berumuron
Browse files

Ajoute l'article Ergogames 6

parent 46f87f02
---
title: Une histoire de pizzas : les étapes
date: 2019-12-31 09:30
---
Après une petite pause, voici le sixième épisode de [ma série
d’articles](serie/ergogames.html) retraçant la conception d’un mini-jeu vidéo.
Comme d’habitude, vous êtes invité·es à lire les articles précédents pour vous
y retrouver.
Aujourd’hui, on va faire simple : on va afficher la liste des étapes que le
joueur devra suivre pour avancer dans le jeu. Le gros du travail se fera dans
l’article suivant.
Pour ce faire, on va commencer par déclarer les étapes en JavaScript :
```javascript
// On déclare les étapes dans un objet...
const steps = [
{ id: 'STEP_ROLL_OUT_DOUGH', label: 'Étaler la pâte avec les doigts' },
{ id: 'STEP_GRAB_TOMATO', label: 'Aller chercher la sauce tomate sur l’étagère' },
{ id: 'STEP_SPREAD_TOMATO', label: 'Verser la sauce tomate sur la pizza' },
{ id: 'STEP_GRAB_MOZZA', label: 'Aller chercher la mozzarella dans le frigo' },
{ id: 'STEP_SPREAD_MOZZA', label: 'Disposer la mozzarella sur la sauce tomate' },
{ id: 'STEP_GRAB_PIZZA_1', label: 'Prendre la pizza' },
{ id: 'STEP_PUT_PIZZA_IN_OVEN', label: 'Mettre la pizza au four' },
{ id: 'STEP_BAKE_PIZZA', label: 'Cuire 5 secondes' },
{ id: 'STEP_GRAB_PIZZA_2', label: 'Prendre la pizza' },
{ id: 'STEP_DELIVER_PIZZA', label: 'Emporter la pizza jusqu’au passe-plat' },
{ id: 'STEP_FINISH' },
];
const app = new Vue({
el: '#app',
data: {
size: BOARD_SIZE,
store: gameStore,
// ... que l'on passe ensuite à data pour pouvoir y accéder depuis la vue
steps,
},
// ...
});
```
C’est tout bête, rien de bien méchant ici. Il nous faut désormais afficher
cette liste dans le <abbr>HTML</abbr>.
```html
<div id="app">
<div class="steps">
<div class="steps-title">Recette</div>
<transition v-for="(step, index) in steps" :key="step.id">
<div
v-if="step.label"
:class="['steps-item', { current: step.id === currentStep }]"
>
<div class="steps-item-index">{{ index + 1 }}</div>
<div class="steps-item-label">{{ step.label }}</div>
</div>
</transition>
</div>
<!-- ... -->
</div>
```
Vous remarquerez peut-être que je déclare une class `.current` pour indiquer
quelle est l’étape en cours. Néanmoins, je fais appel à une variable
`currentStep` que nous n’avons pas encore déclarée. Il s’agit d’une valeur qui
va évoluer au fil du temps à travers des actions liées aux clics du joueur,
c’est-à-dire dans notre système `onClickSystem`. Cette valeur doit donc être
déclarée dans le store du jeu. Retour dans le JavaScript :
```javascript
let gameStore = [
/*
On ajoute une nouvelle entité au store pour mémoriser l'étape
courante. C'est cette valeur qui va permettre d’avancer dans le jeu
plus tard.
*/
{ id: 'currentStep', value: 'STEP_ROLL_OUT_DOUGH' },
// ...
];
const app = new Vue({
// ...
computed: {
// On déclare une donnée "computed" : il s'agit d'une donnée qui
// est calculée depuis une autre donnée déjà stockée dans data
// (ici, le store). Il s'agit juste de simplifier le code du
// template HTML.
currentStep() {
return getEntity(this.store, "currentStep").value;
},
},
});
```
Avec ça, on a quasiment terminé pour cette fois-ci, on va juste ajouter un peu
de <abbr>CSS</abbr> pour rendre le tout un poil plus joli.
```css
/* Du CSS pour afficher le bloc des instructions à gauche de la zone de jeu */
#app {
display: flex;
justify-content: center;
}
/*
Et du CSS pour styler les instructions elles-mêmes. Ironiquement, il y a
besoin de plus de CSS pour cette partie que pour la zone de jeu.
*/
.steps {
width: 30%;
margin-right: 1rem;
padding: .5rem 1rem;
font-family: sans-serif;
background-color: #eee;
border-radius: .5rem;
}
.steps .steps-title {
margin-bottom: 1rem;
padding-bottom: .25rem;
text-align: center;
border-bottom: 1px solid #ccc;
}
.steps .steps-item {
display: flex;
padding: .25rem .5rem;
align-items: baseline;
color: #666;
font-size: .8rem;
border: .25rem solid transparent;
}
.steps .steps-item.current {
color: #000;
font-weight: bold;
border-color: #00dbcb;
border-radius: .5rem;
}
.steps .steps-item .steps-item-index {
display: flex;
width: 1.25rem;
height: 1.25rem;
margin-right: .25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: #fff;
background-color: #666;
border-radius: 50%;
}
.steps .steps-item.current .steps-item-index {
color: #000;
background-color: #00dbcb;
}
```
Avant de finir cet article, je veux juste pointer une petite différence
vis-à-vis du jeu initial. J’ai fait le choix ici d’afficher toutes les
instructions pour simplifier légèrement le code et ne pas rentrer dans des
détails inutiles d’implémentation. Dans le jeu des Ergogames, seulement deux
instructions sont affichées à la fois : l’étape courante et l’étape suivante.
Si le détail technique n’est pas important, il l’est cependant d’un point de
vue utilisabilité. Cette partie a beaucoup évolué avec les retours des
utilisateurs et utilisatrices, notamment pour satisfaire la contrainte de
l’écran réduit sur mobile. Ce n’est d’ailleurs même pas moi qui aie fait les
derniers changements.
Il ne vous reste plus qu’à tester [le résultat du code de cet article ici](une-histoire-de-pizzas/etape6.html).
Il n’y a rien de plus à faire qu’à regarder puisque nous n’avons pas encore
implémenté les actions qui vont permettre d’avancer dans le jeu. Ce sera
l’objet du prochain article.
---
title: Une histoire de pizzas, étape 6
template: game
---
<a href="../une-histoire-de-pizzas-6.html">Lire l’article associé</a>
<div id="app">
<!-- On ajoute un nouveau bloc affichant les étapes de la recette -->
<div class="steps">
<div class="steps-title">Recette</div>
<transition v-for="(step, index) in steps" :key="step.id">
<div
v-if="step.label"
:class="['steps-item', { current: step.id === currentStep }]"
>
<div class="steps-item-index">{{ index + 1 }}</div>
<div class="steps-item-label">{{ step.label }}</div>
</div>
</transition>
</div>
<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)"
>
<img
v-if="entity.asset"
:src="entity.asset"
alt=""
class="entity-asset"
/>
<img
v-else-if="entity.assets"
:src="entityAssetImage(entity)"
alt=""
class="entity-asset"
/>
<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;
// On déclare les étapes du jeu
const steps = [
{ id: 'STEP_ROLL_OUT_DOUGH', label: 'Étaler la pâte avec les doigts' },
{ id: 'STEP_GRAB_TOMATO', label: 'Aller chercher la sauce tomate sur l’étagère' },
{ id: 'STEP_SPREAD_TOMATO', label: 'Verser la sauce tomate sur la pizza' },
{ id: 'STEP_GRAB_MOZZA', label: 'Aller chercher la mozzarella dans le frigo' },
{ id: 'STEP_SPREAD_MOZZA', label: 'Disposer la mozzarella sur la sauce tomate' },
{ id: 'STEP_GRAB_PIZZA_1', label: 'Prendre la pizza' },
{ id: 'STEP_PUT_PIZZA_IN_OVEN', label: 'Mettre la pizza au four' },
{ id: 'STEP_BAKE_PIZZA', label: 'Cuire 5 secondes' },
{ id: 'STEP_GRAB_PIZZA_2', label: 'Prendre la pizza' },
{ id: 'STEP_DELIVER_PIZZA', label: 'Emporter la pizza jusqu’au passe-plat' },
{ id: 'STEP_FINISH' },
];
let gameStore = [
/*
On ajoute une nouvelle entité au store pour mémoriser l'étape
courante. C'est cette valeur qui va permettre d’avancer dans le jeu
plus tard.
*/
{ id: 'currentStep', value: 'STEP_ROLL_OUT_DOUGH' },
{
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 },
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,
// On ajoute les steps à data pour les afficher dans la vue
steps,
},
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',
};
},
entityAssetImage(entity) {
if (entity.assets == null || entity.direction == null) {
return '';
}
return entity.assets[entity.direction];
},
onBoardClick(e) {
this.store = onClickSystem(e, this.store);
},
},
computed: {
// On déclare une donnée "computed" : il s'agit d'une donnée qui
// est calculée depuis une autre donnée déjà stockée dans data
// (ici, le store). Il s'agit juste de simplifier le code du
// template HTML.
currentStep() {
return getEntity(this.store, "currentStep").value;
},
},
});
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;
}
const meiko = getEntity(store, 'meiko');
if (!positionIsAccessibleFor(meiko, position)) {
return store;
}
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 });
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">
#app {
--board-size: 6;
--cell-size: 128px;
--color-floor: #fff;
--color-floor-border: #ccc;
}
* {
box-sizing: border-box;
}
/* Du CSS pour afficher le bloc des instructions à gauche de la zone de jeu */
#app {
display: flex;
justify-content: center;
}
.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;
z-index: 100;
}
.board-cell-entity img {
max-width: 100%;
}
.board-cell-entity.mozza img {
max-width: 85%;
}
.board-cell-entity.dough,
.board-cell-entity.mozza,
.board-cell-entity.tomato {
z-index: 10;
}
.board-cell-entity.meiko {
z-index: 20;
}
/*
Et du CSS pour styler les instructions elles-mêmes. Ironiquement, il y a
besoin de plus de CSS pour cette partie que pour la zone de jeu.
*/
.steps {
width: 30%;
margin-right: 1rem;
padding: .5rem 1rem;
font-family: sans-serif;
background-color: #eee;
border-radius: .5rem;
}
.steps .steps-title {
margin-bottom: 1rem;
padding-bottom: .25rem;
text-align: center;
border-bottom: 1px solid #ccc;
}
.steps .steps-item {
display: flex;
padding: .25rem .5rem;
align-items: baseline;
color: #666;
font-size: .8rem;
border: .25rem solid transparent;