Gérer les ressources du moteur 2D

Je le disais dans mon précédent article, lorsque le moteur grandi il devient important de savoir quand exécuter certaine tâche et définir des priorités.
Et donc pour ça il faut pouvoir dire où et quand on veut charger des ressources.

L’exemple de l’image est parfait, une image peut être volumineuse, par exemple parce qu’elle contient toutes les frames d’animation de tous les personnages du jeu, ou qu’elle contient toutes les images des éléments statiques du jeu. Le temps du chargement sera forcément dépassé par la vitesse d’exécution de votre code, au final l’image ne sera pas affichée.

On va donc créer un système qui nous permettra de prendre les ressources en mains quand on le voudra.

Codons un peu

class ResourceSystem {
    constructor() {
        this.resources = {};
    }
}

On défini une classe ResourceSystem, tous nos prochains systèmes devront porter un nom avec System à la fin. Et on les rangera dans un dossier System.

J’isole ce bout de code parce qu’en fait le choix ici influence la suite du code.
– Soit on choisi de faire un Array
– Soit on choisi de faire un Objet Javascript

Perso j’ai défini un Objet JS, mais on va d’abord faire un code comme si on avait choisi un Array pour voir la différence, tapons la fonction d’ajout d’une image :

    /**
     * addImage
     * @description Ajoute une image aux tableau des ressources
     * @param name
     * @param src
     * @returns {Promise<unknown>}
     */
    addImage(name, src) {
        // On utilise une promise pour pouvoir attendre le chargement
        // complet de l'image.
        return new Promise((resolve, reject) => {
            // On créé un nouvel élément image
            let image = new Image();
            // On défini l'adresse source (url, url:data)
            image.src = src;
            // On défini la fonction callback onload
            image.onload = () => {
                // On résolve la promise seulement quand l'image est
                // complètement chargée
                resolve();
            };

            // On vérifie si des resource image ont déjà été inserée
            // Si ce n'est pas le cas on créé le type image.
            if(!this.resources['image']) this.resources['image'] = [];
            this.resources['image'][name] = image;
        });
    }

    /**
     * getAsset
     * @description retourne une ressource selon son type et son nom
     * @param type
     * @param name
     * @returns {*}
     */
    getAsset(type, name) {
        return this.resources[type][name];
    }

Cette approche est simple et plutôt efficace, on défini des catégorie en fonction du contenu et on injecte directement la ressource.
Pour récupérer la ressource, il suffit de check dans le bon type et donner le nom pour la renvoyer.
Je suis un peu maniaque parfois et j’aime bien ranger les choses, surtout dans un projet qui prend de l’ampleur, donc je vais préférer une solution plus dynamique. Voyons donc l’approche avec l’Objet Javascript :

    /**
     * addImage
     * @description Ajoute une image aux tableau des ressources
     * @param name
     * @param src
     * @returns {Promise<unknown>}
     */
    addImage(namespace, src) {
        // On utilise une promise pour pouvoir attendre le chargement
        // complet de l'image.
        return new Promise((resolve, reject) => {
            // On créé un nouvel élément image
            let image = new Image();
            // On défini l'adresse source (url, url:data)
            image.src = src;
            // On défini la fonction callback onload
            image.onload = () => {
                // On résolve la promise seulement quand l'image est
                // complètement chargée
                resolve();
            };
            
            // Enfin on ajoute la ressource dans le ResourceSystem
            this.define(namespace, image);
        });
    }

    /**
     * define
     * @description Défini une ressource selon un namespace précis
     * @param namespace
     * @param resource
     */
    define(namespace, resource) {
        // On split le namespace à chaque point
        let parts = namespace.split('.');
        // On défini la racine de notre namespace comme étant le contexte
        // global de l'instance de notre ResourceSystem
        let root = this;

        // On parcours le namespace
        for(let i = 0; i < parts.length; ++i) {
            // Tans qu'on est pas dans le dernier nom on redéfini
            // la racine.
            if(parts.length - 1 > i) {
                // Si le nom d'espace suivant n'existe pas on le créé
                if(!root[parts[i]]) root[parts[i]] = {};
                root = root[parts[i]]; // Puis on redéfini la racine
            } else {
                // sinon on défini la resource
                root[parts[i]] = resource;
            }
        }
    }

Avec un peu plus de code, on peut intégrer la notion de namespace (espace de nom) dans la gestion de nos ressource. Par exemple, je pourrais définir non pas une image dans un type image, mais bien une image dans une arborescence qui à une logique, qui est organisée, par exemple un chemin du style :

ResourceSystem.Entity.MonPersonnage.Images.monperso

Maintenant qu’on gère nos ressources, modifions un poil notre fonction image() dans le DrawSystem :

    /**
     * image
     * @description Dessine une image donnée
     * @param src
     * @param x
     * @param y
     * @param w
     * @param h
     */
    image(image, x, y, w, h) {
        this.ctx.drawImage(image, x, y, w, h);
    }

Voilà, simple non ? En gros notre fonction ne s’occupera que de recevoir un élément image et de l’afficher correctement. Cela renforce le rôle du Draw, qui n’est que de dessiner des éléments dans le Canvas. Il n’a plus à charger et configurer l’élément image, juste à le dessiner.

Utilisons nos outils

Utilisons maintenant les petits outils que nous avons créé et voyons comment le programme se développe. Pour tester prenez une url qui fonctionne et choisissez le namespace que vous voulez :

const SCREEN_WIDTH  = 500;
const SCREEN_HEIGHT = 500;

// On créé le système de ressources
const resources = new ResourceSystem();
// On créé le système de dessins
const draw = new DrawSystem("game", SCREEN_WIDTH, SCREEN_HEIGHT, '2d');

// On ajoute l'image dans l'objet resources
resources.addImage('images.morgi', './resources/images/morgi.png').then(() => {
    // Et on a plus qu'à demander au DrawSystem de la dessiner
    draw.image(resources.images.morgi, 0, 0, 50, 50);
}).catch((e) => console.error(e));

Alors vu comme ça, c’est pas fifou, on ne fait qu’afficher une seule image après son chargement. Evidemment l’idée c’est d’exploiter la puissance des Promise Javascript, on peut continuer à faire simple et intégrer une nouvelle méthode au ResourceSystem qui nous permettra de prendre en mains un tableau de resources :

    /**
     * loadResources
     * @description Parcours un tableau d'adresse de ressources pour les charger une à une et renvoyer une promise
     * @param resources
     * @returns {Promise<unknown[]>}
     */
    loadResources(resources) {
        // On prépare le tableau qui contiendra nos promises
        let arrayPromise = [];

        // On parcours le tableau
        for(let i in resources) {
            // On vérifie à quel type de ressource est proposé
            if(resources[i].type === 'image') arrayPromise.push(this.addImage(resources[i].namespace, resources[i].src));
        }

        // Et on retourne un Promise.all qui exécutera toutes les 
        // promises une par une.
        return Promise.all(arrayPromise);
    }

Cette fois-ci, on peut se permettre de charger un tableau de ressources et d’en faire ce qu’on veut une fois leur chargement terminé. J’ajouterais même qu’il serait intéressant d’ajouter un petit système dans les promise, permettant au ResourceSystem d’émettre un événement transmettant des informations sur la dernière ressources en cours de chargement.

Actuellement l’utilisation se ferait comme ceci :

const SCREEN_WIDTH  = 500;
const SCREEN_HEIGHT = 500;

// Définition du tableau des resouces
const assets = [
    { type: 'image', namespace: 'images.morgi', src: './ressources/images/morgi.png'},
    { type: 'image', namespace: 'images.personnages.perso', src: './ressources/images/personnages/perso.png' }
];

// On créé le système de ressources
const resources = new ResourceSystem();
// On créé le système de dessins
const draw = new DrawSystem("game", SCREEN_WIDTH, SCREEN_HEIGHT, '2d');

resources.loadResources(assets).then(() => {
    draw.image(resources.images.morgi, 0, 0, 50, 50);
    draw.image(resources.images.personnages.perso, 100, 100, 64, 64);
}).catch((e) => console.error(e));

On a donc maintenant un système qui nous permet de charger des resources et de les dessiner quand on veut. Globalement, on est en train de créer la gestion d’une scène statique.

Dans le prochain article, j’essayerai de parler de création et gestion de scène, on y parlera Entité et SceneManager.
Une scène nous permettra de bien gérer nos ressources, elle définira les ressources qu’elle a besoin de charger et celle qu’elle a besoin d’afficher ou non.
On verra peut-être également la superposition de Scène en Layer, ce qui nous permettra de gérer plusieurs couches qui créeront un ensemble logique permettant d’avoir en même temps l’image du jeu, un hud de contrôle et un menu quand on veut l’afficher.

J’espère que j’ai été assez clair, malgré que les inscriptions soient fermées pour l’instant (je dois régler un soucis de spam), n’hésitez pas à vous inscrire et à laisser un commentaire si vous avez des questions, des suggestions ou un correctif à proposer 😉

Le code entier

/**
 * Une classe DrawSystem permet d'initialiser le canvas quand on veut
 * et rend le code réutilisable
 */
class DrawSystem {
    constructor(id, width, height, context) {
        // Création de l'élément Canvas
        this.canvas = document.createElement("canvas");
        // Création du contexte
        this.ctx = this.canvas.getContext(context || '2d');

        // Initialisation de l'élément Canvas
        this.canvas.id     = id     || "game"; // On lui donne un ID
        this.canvas.width  = width  || 500;    // Une largeur
        this.canvas.height = height || 500;    // Une hauteur

        // Injection de l'élément Canvas
        document.body.appendChild(this.canvas);
    }

    /**
     * clearScreen
     * @description nettoie l'écran entièrement avant la prochaine frame
     * @param w
     * @param h
     */
    clearScreen(w, h) {
        this.ctx.clearRect(0, 0, w, h);
    }

    /**
     * image
     * @description Dessine une image donnée
     * @param image
     * @param x
     * @param y
     * @param w
     * @param h
     */
    image(image, x, y, w, h) {
        this.ctx.drawImage(image, x, y, w, h);
    }

    /**
     * rectangle
     * @description Méthode permettant de dessiner des carrés ou rectangles.
     */
    rectangle(x, y, w, h, options) {
        // On vérifie si l'option de remplissage est définie
        if(options.fill) {
            // Si c'est le cas, on applique une couleur
            this.ctx.fillStyle = options.fill.color;
        }

        // On vérifie si l'option de bordure est définie
        if(options.stroke) {
            // Si c'est le cas, on applique une couleur de bordure
            this.ctx.strokeStyle = options.stroke.color || 'black';
            // On peut définir la largeur du trait
            this.ctx.lineWidth = options.stroke.width || 5;
        }

        // Et on dessine le rectangle
        this.ctx.fillRect(x, y, w, h);
    }
}

/**
 * ResourceSystem
 */
class ResourceSystem {
    constructor() {}

    /**
     * loadResources
     * @description Parcours un tableau d'adresse de ressources pour les charger une à une et renvoyer une promise
     * @param resources
     * @returns {Promise<unknown[]>}
     */
    loadResources(resources) {
        // On prépare le tableau qui contiendra nos promises
        let arrayPromise = [];

        // On parcours le tableau
        for(let i in resources) {
            // On vérifie à quel type de ressource est proposé
            if(resources[i].type === 'image') arrayPromise.push(this.addImage(resources[i].namespace, resources[i].src));
        }

        // Et on retourne un Promise.all qui exécutera toutes les
        // promises une par une.
        return Promise.all(arrayPromise);
    }

    /**
     * addImage
     * @description Ajoute une image aux tableau des ressources
     * @param name
     * @param src
     * @returns {Promise<unknown>}
     */
    addImage(namespace, src) {
        // On utilise une promise pour pouvoir attendre le chargement
        // complet de l'image.
        return new Promise((resolve, reject) => {
            // On créé un nouvel élément image
            let image = new Image();
            // On défini l'adresse source (url, url:data)
            image.src = src;
            // On défini la fonction callback onload
            image.onload = () => {
                // On résolve la promise seulement quand l'image est
                // complètement chargée
                resolve();
            };

            this.define(namespace, image);
        });
    }

    /**
     * define
     * @description Défini une ressource selon un namespace précis
     * @param namespace
     * @param resource
     */
    define(namespace, resource) {
        // On split le namespace à chaque point
        let parts = namespace.split('.');
        // On défini la racine de notre namespace comme étant le contexte
        // global de l'instance de notre ResourceSystem
        let root = this;

        // On parcours le namespace
        for(let i = 0; i < parts.length; ++i) {
            // Tans qu'on est pas dans le dernier nom on redéfini
            // la racine.
            if(parts.length - 1 > i) {
                // Si le nom d'espace suivant n'existe pas on le créé
                if(!root[parts[i]]) root[parts[i]] = {};
                root = root[parts[i]]; // Puis on redéfini la racine
            } else {
                // sinon on défini la resource
                root[parts[i]] = resource;
            }
        }
    }
}

const SCREEN_WIDTH  = 500;
const SCREEN_HEIGHT = 500;

// Définition du tableau des resouces
const assets = [
    { type: 'image', namespace: 'images.morgi', src: './ressources/images/morgi.png'},
    { type: 'image', namespace: 'images.personnages.perso', src: './ressources/images/personnages/perso.png' }
];

// On créé le système de ressources
const resources = new ResourceSystem();
// On créé le système de dessins
const draw = new DrawSystem("game", SCREEN_WIDTH, SCREEN_HEIGHT, '2d');

resources.loadResources(assets).then(() => {
    draw.image(resources.images.morgi, 0, 0, 50, 50);
    draw.image(resources.images.personnages.perso, 100, 100, 64, 64);
}).catch((e) => console.error(e));