La camera et l’isométrie

N’ayant plus beaucoup de temps pour bosser sur le moteur 2D ni pour écrire des articles, j’ai eu du mal à aborder le système de caméra et l’intégrer dans le moteur.
Dans le même temps, j’avais envie d’aborder la construction d’une map en 2D isométrique, là encore j’étais pas sur de mon coup et je n’arrivais pas à franchir le pas de l’intégrer dans le moteur sans savoir de quoi il en retourne.

Je me suis donc dit, part sur quelques chose de simple, teste les concept sans les intégrer dans le moteur et tu verras après.

Vous pouvez voir un exemple fonctionnel à cette adresse :
http://demo.morgiver.net/camera-iso/

Tuiles visibles de la map isométrique

Comme je me suis dit que je partais sur un terrain vierge j’ai donc recréé deux fichier, un index.html avec le fichier engine dedans.
J’initialise le canvas rapidement et on est prêt à commencer :

Index.html :

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
    </head>
    <body>
        <script src="engine.js"></script>
    </body>
</html>

Engine.js :

let canvas = document.createElement('canvas');
let context = canvas.getContext("2d");

canvas.id     = "game";
canvas.width  = 800;
canvas.height = 600;

document.body.appendChild(canvas);

Les objectifs sont assez clairs

  • Générer une map de X tuiles de large et Y tuiles de haut.
  • Avoir une camera capable de zoomer et dezoomer sur la map
  • Avoir une camera capable de se mouvoir et n’afficher qu’une partie de la map.

Construire la map

Résultat de recherche d'images pour "isométrie"
Source: Wikipédia

La perspective isométrique c’est une représentation qui permet de représenter les 3 dimensions.

C’est ce qui nous permet donc de faire de la 3D avec de la 2D en jouant sur les perspective.

Je ne vais pas approfondir le sujet de l’isométrie car c’est un sujet super vaste et je suis persuadé de ne pas avoir assez de connaissances et de compétences pour en parler. Ici, on veut juste atteindre un but de recherches 😉

Pour notre map donc, on veut pouvoir avoir une surface en quadrillage qui puisse nous donner l’illusion que nous avons un tableau de carrés couchés comme dans l’image au début de l’article.

  • Il faut que le quadrillage se fasse en utilisant un tableau de lignes contenant un tableau de colonnes
  • Il faudra pouvoir numéroter dans l’ordre toutes les cases.

Avec la vue en perspective, les angles 2D changent, comme la petite image le montre (et si vous allez sur Wikipédia), on se retrouve donc avec des losanges. Tout le travail va donc d’être de générer les losanges dans le bon ordre et au bon endroit.

Codons un peu

Commençons par définir des variables dont on aura besoin un peu partout et qui nous servirons plus tard.

let canvas = document.createElement('canvas');
let context = canvas.getContext("2d");

canvas.id     = "game";
canvas.width  = 800;
canvas.height = 600;

document.body.appendChild(canvas);

// On prédéfini une variable de Zoom
let zoom   = 10;
// On prédéfini des variable de grandeur pour les
// hauteur et largeurs de nos losanges.
let height = null;
let width  = null;

// On défini des constantes minimum pour la taille des
// losanges.
const MIN_WIDTH  = 5;
const MIN_HEIGHT = 3;

La valeur de zoom, comme dit précédemment, on en aura besoin.
Nos losanges auront une taille variable, donc il est bon de pouvoir prévoir une taille minimum et deux variables qui pourront contenir les tailles actuelles.

La fonction drawLosange est l’utilisation du canvas pour enchaîner des paths afin de pouvoir dessiner les cotés du losange puis le remplir avec une couleur :

/**
 * drawLosange
 * @description reçois des points d'origine et une couleur pour dessiner
 *              un losange
 * @param x
 * @param y
 * @param color
 */
function drawLosange(x, y, color) {
    // On défini une couleur éventuelle pour les cotés
    context.strokeStyle = "gray";

    // On ouvre le path
    context.beginPath();
    // On défini la tailles de pointillés des cotés.
    context.setLineDash([5, 5]);

    // On défini les différents points de chemins
    let pointOne   = { x: x, y: y };
    let pointTwo   = { x: pointOne.x + width, y: pointOne.y + height };
    let pointThree = { x: pointTwo.x - width, y: pointTwo.y + height };
    let pointFour  = { x: pointThree.x - width, y: pointThree.y - height };

    // Enfin on bouge de point en point pour dessiner les contours du losange
    context.moveTo(pointOne.x, pointOne.y);
    context.lineTo(pointTwo.x, pointTwo.y);
    context.lineTo(pointThree.x, pointThree.y);
    context.lineTo(pointFour.x, pointFour.y);
    context.lineTo(pointOne.x, pointOne.y);

    // On ferme le path
    context.closePath();

    // On remplir le losange avec une couleur donnée.
    context.fillStyle = color;
    context.fill("nonzero");

    // On dessine les arrêtes pointillées
    context.stroke();
}

// Pour tester tout ça on défini les variable width et height
width  = MIN_WIDTH * zoom;
height = MIN_HEIGHT * zoom;

// Et on dessine un losange
drawLosange(100, 100, "white");

Résultat simple et sympa :

Portons notre regard sur la façon dont on dessine les contours. On par du pointOne. Qui est le sommet supérieur du losange. Cela défini également sont point d’origine et ça, il faudra le garder en tête pour plus tard dans le code.

Dessiner le quadrillage

Pour la suite on va devoir définir une fonction qui permette de dessiner l’ensemble des losanges, on l’utilisera comme fonction de dessin générale, qu’on appelera avec requestAnimationFrame :

// Comme on veut une map on défini les largeur et hauteur de la map
// Valeur en nombre de losanges évidemment.
let widthMap  = 2;
let heightMap = 2;

// On défini un point d'origine pour la map toute entière
let originMap = { x: canvas.width / 2, y: 0 };

function draw() {
    // On se créé une variable de comptage de losanges. Valeur qu'on
    // affichera au centre de chaque losange.
    let countCase = 0;
    // Contiendra la couleur des losanges.
    let color = null;
    // On défini les largeur et hauteur des losanges
    width  = MIN_WIDTH * zoom;
    height = MIN_HEIGHT * zoom;

    // On clear le canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    // On est clairement dans une logique de positionnement on utilisera
    // donc des boucles imbriquées, comme si on était dans des tableaux.
    for(let i = 1; i <= heightMap; ++i) {
        for(let j = 1; j <= widthMap; ++j) {
            // On défini le point d'origine du losange en fonction de sa 
            // position dans le tableau et sur le canvas.
            let x = originMap.x - (i * width) + j * width;
            let y = originMap.y + (i * height) + (j * height);

            // On choisi une couleur de manière alternée en fonction
            // si on se trouve dans une case paire ou impaire sur une 
            // ligne paire ou impaire. Attention les yeux :p
            if(i % 2 === 0) {
                if(j % 2 !== 0) color = "orange";
                else color = "gray";
            } else {
                if(j % 2 !== 0) color = "gray";
                else color = "orange";
            }
            
            // On dessine le losange
            drawLosange(x,  y, color);
            // On redéfini la couleur de remplissage sur noir
            context.fillStyle = "black";
            // Et on insère le texte de numéro de losange
            context.fillText(countCase, x, y + height);
            // Enfin on incrémente le numéro de losange.
            ++countCase;
        }
    }
}

draw();

Résultat pas dégueu :

Le point important c’est la position d’origine du losange. On gère cela comme un tableau, on dessine les ligne une par une. L’axe des X représentant les colonnes et l’axe des Y représentant les lignes.

A chaque nouvelle colonne, le losange doit être décalé sur l’axe des Y, sur l’image on le voit bien, le losange 0 est plus haut que le losange 1, pourtant ils sont sur la même ligne.
Et c’est la même chose à chaque nouvelle ligne, on décale chaque nouvelle ligne sur l’axe des X. On le voit bien avec les losanges 2 il est placé plus à gauche que le losange 0.

Il était important de noter les cases des losanges pour pouvoir voir s’ils sont dessiner dans le bon ordre. En effet, il faut pouvoir se rappeler qu’on est dans le cas d’un tableau, on a défini des tailles standard et si on veut pouvoir jouer avec des données de position 2D il faudra pouvoir relier les entités aux losanges facilement.

En dessinant tous mes losanges, pour chaque position sur le plan, je peux tester un autre tableau contenant des entités par exemple qui elles pourraient afficher des personnages, des objets, etc., etc.

Gérer la camera

Bon maintenant qu’on a droit à un quadrillage de la grandeur qu’on veut, essayons de voir plus grand et de se déplacer sur une map plus grande que le canvas.

On va d’abord modifier et rajouter la fonction requestAnimationFrame à la fin de la fonction draw() : (attention code raccourci ne copier coller pas, tapez le code par vous même dans la fonction).

function draw() {
    [...]
    for(let i = 1; i <= heightMap; ++i) {
        for(let j = 1; j <= widthMap; ++j) {
            [...]
        }
    }
    // On appel la fonction requestAnimationFrame et on injecte draw 
    // comme fonction callback
    requestAnimationFrame(draw);
}

draw();

Pour le test on va mettre les variable widthMap et heightMap sur 128 et à ce stade ci vous devriez avoir un résultat de ce type :

Pour pouvoir gérer la caméra et la déplacer dans toute la map on va avoir besoin de détecter la souris.
On va faire simple, quand on clic et reste appuyer sur le clic on déplace la map sur le canvas, on aura donc l’illusion de se déplacer sur la map.

La variable originMap va nous être extrêmement utile pour cela, car c’est elle qui défini l’origine de la map dans le canvas !

// On défini une variable qui nous dira si on presse ou non le clic de
// souris
let pressing = false;

// On ajoute un listener quand le clic de souris est pressé
document.addEventListener('mousedown', (event) => {
    // La variable de presse est donc sur true
    pressing = true;
});
// On ajoute un listener quand le clic de souris est relaché
document.addEventListener('mouseup', (event) => {
    // La variable de presse est donc sur false
    pressing = false;
});

// On ajoute un listener pour savoir si la souris bouge
document.addEventListener('mousemove', (event) => {
    // Si on presse le clic c'est qu'on veut bouger la map
    if(pressing) {
        // On redéfini la position x et y avec les données
        // de mouvements fournie par la souris.
        originMap.x = originMap.x + event.movementX;
        originMap.y = originMap.y + event.movementY;
    }
});

// On ajoute un listener pour savoir si on bouge la molette
document.addEventListener('wheel', (event) => {
    // Quand la molette est bougée, on redéfini la valeur
    // de la variable de zoom.
    zoom = zoom + (-1 * (event.deltaY * 0.1));
    // Si la variable est plus petite que 1 on la redéfini
    // à 1, ça évite un bug qui fait disparaitre la map
    if(zoom < 1) zoom = 1;
});

// et enfin, on lance le tout, enjoy !
draw();

Et là, c’est le drame !

Comme vous pourrez le constater, c’est UBER LENT, ça rame à fond !
Et c’est tout à fait normal, on dessine 16384 losange et on affiche 16348 texte aussi.

La question a se poser est : doit-on vraiment dessiner les losanges qui n’apparaissent pas parce qu’ils sont en dehors des limites du canvas ?

La réponse est simple : Non. C’est une perte drastique de performance !

Et en fait, la solution est ultra simple aussi ! On va modifier ce qu’il se passe dans la fonction Draw pour éviter de dessiner ce qui dépasse ! Et ça se résume à rajouter une condition : (attention code raccourci ne copier coller pas, tapez le code par vous même dans la fonction).

function draw() {
    [...]
    // On est clairement dans une logique de positionnement on utilisera
    // donc des boucles imbriquées, comme si on était dans des tableaux.
    for(let i = 1; i <= heightMap; ++i) {
        for(let j = 1; j <= widthMap; ++j) {
            [...]

            // On vérifie si le losange à dessiner est bien dans les
            // limites du canvas. S'il ne l'est pas, on dessine pas.
            if(x > 100 && x < 700 && y > 100 && y < 500) {
                // On choisi une couleur de manière alternée en fonction
                // si on se trouve dans une case paire ou impaire sur une
                // ligne paire ou impaire. Attention les yeux :p
                if(i % 2 === 0) {
                    if(j % 2 !== 0) color = "orange";
                    else color = "gray";
                } else {
                    if(j % 2 !== 0) color = "gray";
                    else color = "orange";
                }

                // On dessine le losange
                drawLosange(x,  y, color);
            }
            // Enfin on incrémente le numero de losange.
            ++countCase;
        }
    }

    [...]
}

Là le résultat que vous devriez avoir ressemble à ça :

Vous remarquerez également que j’ai enlevé l’affichage des numéro de case, ça bouffe énormément les ressources avec le zoom au minimum.

Si vous regardez bien, dans la condition j’ai défini des valeur plus petite que la taille du canvas, ceci pour pouvoir voir concrètement les limites. Hésitez pas à vous amuser à les changer, à mettre un cadrage autour de votre canvas pour voir mieux encore l’effet.

En dézoomant complètement on sent encore les limites graphique du navigateur. Pour cela je n’ai pas vraiment de solution, cela dit on parle quand même de plus de 16000 entrées, ce qui n’est pas rien !
En zoom normal c’est super fluide par contre !

Conclusions !

Je me rend compte que revenir sur du canvas et développement vierge ça permet vraiment de me libérer du cadre que je m’impose avec le moteur et j’apprécie particulièrement arriver à résoudre des questions qui paraissent super compliqué à cause de ce cadre, mais qui deviennent vraiment simple et amusante dans un cadre vierge.

J’espère que l’article vous aura plus, n’hésitez pas à mettre un commentaire si vous avez eu des soucis ou si vous avez des questions !

A bientôt !

Code complet

let canvas = document.createElement('canvas');
let context = canvas.getContext("2d");

canvas.id     = "game";
canvas.width  = 800;
canvas.height = 600;

document.body.appendChild(canvas);

// On prédéfini une variable de Zoom
let zoom   = 10;
// On prédéfini des variable de grandeur pour les
// hauteur et largeurs de nos losanges.
let height = null;
let width  = null;

// On défini des constantes minimum pour la taille des
// losanges.
const MIN_WIDTH  = 5;
const MIN_HEIGHT = 3;

/**
 * drawLosange
 * @description reçois des points d'origine et une couleur pour dessiner
 *              un losange
 * @param x
 * @param y
 * @param color
 */
function drawLosange(x, y, color) {
    // On défini une couleur éventuelle pour les cotés
    context.strokeStyle = "gray";

    // On ouvre le path
    context.beginPath();
    // On défini la tailles de pointillés des cotés.
    context.setLineDash([5, 5]);

    // On défini les différents points de chemins
    let pointOne   = { x: x, y: y };
    let pointTwo   = { x: pointOne.x + width, y: pointOne.y + height };
    let pointThree = { x: pointTwo.x - width, y: pointTwo.y + height };
    let pointFour  = { x: pointThree.x - width, y: pointThree.y - height };

    // Enfin on bouge de point en point pour dessiner les contours du losange
    context.moveTo(pointOne.x, pointOne.y);
    context.lineTo(pointTwo.x, pointTwo.y);
    context.lineTo(pointThree.x, pointThree.y);
    context.lineTo(pointFour.x, pointFour.y);
    context.lineTo(pointOne.x, pointOne.y);

    // On ferme le path
    context.closePath();

    // On remplir le losange avec une couleur donnée.
    context.fillStyle = color;
    context.fill("nonzero");

    // On dessine les arrêtes pointillées
    context.stroke();
}

// Comme on veut une map on défini les largeur et hauteur de la map
// Valeur en nombre de losanges évidemment.
let widthMap  = 128;
let heightMap = 128;

// On défini un point d'origine pour la map toute entière
let originMap = { x: canvas.width / 2, y: 0 };

function draw() {
    // On se créé une variable de comptage de losanges. Valeur qu'on
    // affichera au centre de chaque losange.
    let countCase = 0;
    // Contiendra la couleur des losanges.
    let color = null;
    // On défini les largeur et hauteur des losanges
    width  = MIN_WIDTH * zoom;
    height = MIN_HEIGHT * zoom;

    // On clear le canvas
    context.clearRect(0, 0, canvas.width, canvas.height);

    // On est clairement dans une logique de positionnement on utilisera
    // donc des boucles imbriquées, comme si on était dans des tableaux.
    for(let i = 1; i <= heightMap; ++i) {
        for(let j = 1; j <= widthMap; ++j) {
            // On défini le point d'origine du losange en fonction de sa
            // position dans le tableau et sur le canvas.
            let x = originMap.x - (i * width) + j * width;
            let y = originMap.y + (i * height) + (j * height);

            // On vérifie si le losange à dessiner est bien dans les
            // limites du canvas. S'il ne l'est pas, on dessine pas.
            if(x > 100 && x < 700 && y > 100 && y < 500) {
                // On choisi une couleur de manière alternée en fonction
                // si on se trouve dans une case paire ou impaire sur une
                // ligne paire ou impaire. Attention les yeux :p
                if(i % 2 === 0) {
                    if(j % 2 !== 0) color = "orange";
                    else color = "gray";
                } else {
                    if(j % 2 !== 0) color = "gray";
                    else color = "orange";
                }

                // On dessine le losange
                drawLosange(x,  y, color);
            }
            // Enfin on incrémente le numero de losange.
            ++countCase;
        }
    }

    // On appel la fonction requestAnimationFrame et on injecte draw
    // comme fonction callback
    requestAnimationFrame(draw);
}

// On défini une variable qui nous dira si on presse ou non le clic de souris
let pressing = false;

// On ajoute un listener quand le clic de souris est pressé
document.addEventListener('mousedown', (event) => {
    // La variable de presse est donc sur true
    pressing = true;
});
// On ajoute un listener quand le clic de souris est relaché
document.addEventListener('mouseup', (event) => {
    // La variable de presse est donc sur false
    pressing = false;
});

// On ajoute un listener pour savoir si la souris bouge
document.addEventListener('mousemove', (event) => {
    // Si on presse le clic c'est qu'on veut bouger la map
    if(pressing) {
        // On redéfini la position x et y avec les données
        // de mouvements fournie par la souris.
        originMap.x = originMap.x + event.movementX;
        originMap.y = originMap.y + event.movementY;
    }
});

// On ajoute un listener pour savoir si on bouge la molette
document.addEventListener('wheel', (event) => {
    // Quand la molette est bougée, on redéfini la valeur
    // de la variable de zoom.
    zoom = zoom + (-1 * (event.deltaY * 0.1));
    // Si la variable est plus petite que 1 on la redéfini
    // a 1, ça évite un bug qui fait disparaitre la map
    if(zoom < 1) zoom = 1;
});

// et enfin, on lance le tout, enjoy !
draw();