Construction de l’Entity Component System

Actuellement on est devant plusieurs problématiques, comment construire des entités configurables à souhait ? Comment intégrer des fonctionnalités aussi simplement qu’en ajoutant une configuration ? D’ailleurs comment ajouter ou retirer une fonctionnalité d’une entité en jeu ? Comment rendre une entité polymorphe ?

Les Component ou Composants sont la réponse et en fait tout le pattern Entity Component System est la réponse.
Le fonctionnement est simple, une Entity au départ doit être un objet vide auquel on va lui coller des Components pour lui donner des fonctionnalités via les Systems
Par exemple on voudrait dessiner un Rectangle Rouge, de grandeur 50×50, à une position x -> 5 et y -> 5 et on voudrait qu’il bouge dans une certaine direction. Tout ça est composable avec :

  • Composant Size : qui donne une hauteur et une largeur
  • Composant Position : qui donne deux points x et y pour une position sur le plan 2D, on pourrait rajouter un point Z pour rendre la position 3D
  • Composant RenderRectangle : qui comprend une configuration concernant sa couleur et pourquoi pas une donnée sur une éventuelle bordure (on pourrait aussi en faire un composant).
  • Composant Speed: donne deux points x et y pour exprimer une vitesse par seconde sur l’axe X et l’axe Y.

Tous ces composants inclus dans l’entité seront analysés et seront utilisés par les systèmes qui en ont besoin. Par exemple, le composant Speed pourrait être utilisé par un Move System qui serait fait pour mouvoir l’entité dans le plan. Le Pattern ECS donne vraiment BEAUCOUP de possibilités !

Avant de commencer, je voudrais rappeler le contexte de ces articles et le niveau de connaissance qui en ressort. J’écris et utilise ces articles comme carnet de note, mes recherches sont assez globales et survol certains sujet car j’aime aussi réfléchir et chercher par moi même.
Le moteur que je développe n’est pas là du tout pour faire des projets pro ou conséquent, je fais ce moteur pour être confronté à des problématiques et être suffisamment créatif pour résoudre ces challenges. L’idée étant de pouvoir aborder des moteur pro plus tard avec un petit bagages de connaissances.

Donc si vous êtes habitué aux design pattern et avez déjà étudié et appliqué l’Entity Component System ou le Composition Over Inheritance, sachez que je ne les maîtrises pas et que, comme dit plus haut, je me fais une idée personnelle de ce que c’est et de comment je pourrais appliquer ces principes en cherchant par moi même. C’est ma façon d’apprendre et de m’approprier certaines connaissances. Une fois appliqué à ma sauce, après avoir cherché par moi même, je m’attaque aux structures plus standard histoire de pas réinventer la roue et de parfaire mes connaissances.

Bref! Commençons !

Faisons table rase et codons un peu

Comme on va devoir modifier pas mal de choses, faisons table rase et repartons de zéro.

Tout d’abord, partons d’une nouvelle classe qui rassemblera l’ensemble de notre projet, l’Engine. Il servira de racine pour l’ensemble du projet, par exemple pour accéder à des classes de composant, aux ressources, etc…
On lui intégrera un système de namespace et une fonction require pour faciliter l’accès à l’ensemble des éléments du moteur. C’est d’une certaine manière, composer le moteur de plein de composants différents aussi 😉

Les besoins sont simples, on a besoin d’une méthode qui défini un espace de nom pour une valeur et d’une méthode qui récupère une valeur à l’intérieur de l’espace de nom en fonction d’une chaîne de caractère qu’on donne :

class Engine {
    constructor() {}
    /**
     * define
     * @description Définie une valeur en fonction d'un namespace donné
     * @param namespace
     * @param value
     */
    define(namespace, value) {
        // On split le namespace a chaque "."
        let parts = namespace.split('.');
        // Définition de l'instance comme racine
        let root  = this;

        // On parcours le namespace
        for(let i in parts) {
            // Tant que le curseur est pas en derniere
            // position on redéfini la racine comme le
            // dernier espace de nom.
            if(i < parts.length - 1) {
                if(!root[parts[i]]) {
                    root[parts[i]] = {};
                }

                root = root[parts[i]];
            } else {
                // Si on est en derniere position
                // on défini la valeur.
                root[parts[i]] = value;
            }
        }
    }

    /**
     * require
     * @description Retourne une valeur selon un namespace
     * @param namespace
     * @returns {Engine}
     */
    require(namespace) {
        // On split le namespace a chaque "."
        let parts = namespace.split('.');
        // Définition de l'instance comme racine
        let root  = this;

        // On parcours le namespace
        for(let i in parts) {
            // Si l'espace de nom est pas connu
            // on renvoie une erreur
            if(!root[parts[i]]) {
                console.error(new Error(`Unknown namespace ${i} in ${namespace}`));
                break;
            }
            else root = root[parts[i]]; // sinon on redéfini la racine
        }

        return root;
    }
}

Avec ce système de namespace on pourra par exemple définir quels sont les classes de composant chargeables par les entités, les classes des Entités elles mêmes. Ou encore on pourra définir des chemins d’accès aux ressources (image, etc..).

Attaquons l’Entity Component System

Premièrement, partons de l’entité. Elle doit être la plus petite possible et doit correspondre au plus petit dénominateur commun. On partira donc d’une classe ayant pour unique attribut un identifiant unique.

/**
 * Entity
 * @description Plus petit dénominateur commun des Entités
 */
class Entity {
    constructor() {
        this._id = null;
    }
}

Chaque fois que l’Entity Manager créera une entité, il devra lui fournir un identifiant unique. L’Entity Manager devra également lancer la construction de l’entité par rapport aux configurations données puis l’intégrer dans le tableau des entités.
Pour coder tout ça, on va d’abord déléguer la construction des entités à un builder qu’on nommera EntityBuilder. Son rôle sera de créer une nouvelle instance d’entité et de la construire en fonction des configurations données, par exemple une liste de composants.

/**
 * Entity Builder
 * @description Se charge de recevoir les configuration et les appliquer à
 *              une nouvelle instance d'entité.
 */
class EntityBuilder {
    constructor(Engine, id) {
        // On défini l'Engine Root pour la gestion du require
        this.Engine = Engine;
        // On créé l'instance de l'entité avec l'identifiant fourni
        this.entity = new Entity(id);
    }

    /**
     * require
     * @description voir Require dans Engine
     * @param namespace
     * @returns {*}
     */
    require(namespace) {
        return this.Engine.require(namespace);
    }

    /**
     * addComponent
     * @description Ajoute et configure un composant dans l'entité
     * @param namespace
     * @returns {EntityBuilder}
     */
    addComponent(namespace, parameters) {
        // Récupération de la class de composant
        let Component     = this.require(namespace);
        // Si le composant donné est indéfini
        if(!component) return this;
        // Récupération du nom du composant
        let name          = namespace.split('.').pop();
        // Intégration d'une nouvelle instance de composant
        // dans l'entité
        this.entity[name] = new Component(parameters);

        return this;
    }

    /**
     * build
     * @description Construit l'entité selon la configuration donnée
     * @param configuration
     * @returns {Entity}
     */
    build(configuration) {
        for(let i in configuration) {
            this.addComponent(configuration[i].namespace, configuration[i].parameters);
        }

        return this.entity;
    }
}

Maintenant qu’on sait qu’on peut construire une entité, on peut attaquer le développement de l’EntityManager. Son rôle à lui sera donc de lancer des procédure de création des entités et de les intégrer dans le tableau.
Il devra également parcourir le tableau et effectuer des tâches telle que lancer une mise à jour d’entité ou lancer la procédure d’affichage.
Voici ce que ça pourrait donner :

/**
 * Entity Manager
 */
class EntityManager {
    constructor(Engine) {
        this.Engine   = Engine;
        this.entities = [];
    }

    /**
     * require
     * @description Voir require dans Engine
     * @param namespace
     * @returns {Engine|*}
     */
    require(namespace) {
        return this.Engine.require(namespace);
    }

    /**
     * buildEntity
     * @description Défini un ID unique et construit l'entité
     * @param configuration
     * @returns {Entity}
     */
    buildEntity(configuration) {
        /**
         * makeId
         * @description Créé un identifiant en fonction d'une taille
         *              définie
         * @param length
         * @returns {string}
         */
        function makeId(length = 8) {
            let text     = "";
            let possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

            for (let i = 0; i < length; i++)
                text += possible.charAt(Math.floor(Math.random() * possible.length));

            return text;
        }

        // Création d'un identifiant unique
        let id = makeId(16);

        // On créé une mini boucle qui vérifie si l'id est unique
        // Tant que l'id n'est pas unique, il est recréé.
        while(this.entities[id] !== undefined) id = makeId(16);

        // On retourne un nouveau build d'un EntityBuilder.
        return (new EntityBuilder(this.Engine, id)).build(configuration);
    }

    /**
     * createEntity
     * @description Créé une nouvelle entité identifiée de manière
     *              unique et l'intègre dans le tableau d'entités
     * @param configuration
     * @returns {EntityManager}
     */
    createEntity(configuration) {
        // Construit l'entité
        let newEntity = this.buildEntity(configuration);
        // Intègre l'entité dans le tableau des entités
        this.entities[newEntity._id] = newEntity;
        // Retourne l'instance du manager
        return this;
    }
}

Si on regarde un peu plus en détail la construction d’une entité, l’injection des Component est relativement simple, on prend le nom du Component et on lui attribue une instance de ce Component (avec les paramètres donnés) dans l’entité.
On verra par la suite que le nom du composant est important car il sera repris dans les systèmes.

Maintenant on peut donc créer de multiples entités en fonction de configuration données !
Il nous reste une petite chose importante à faire avant de passer à la suite, définir une instance de l’EntityManager dans l’espace de nom depuis le constructeur de l’Engine, comme ceci :

class Engine {
    constructor() {
        this.define('Managers.EntityManager', new EntityManager(this));
    }
    //... Reste des méthodes
}

Désormais notre EntityManager sera accessible partout, d’ailleurs si vous ajoutez le code suivant, vous pourrez voir la structure actuelle du namespace de l’Engine :

let myEngine = new Engine();
console.log(myEngine);

Nos premiers Component

Occupons nous des Component maintenant qu’on peut créer et configurer des entités. On va partir de notre exemple du début :

/**
 * PositionComponent
 * @description Défini des coordonnées x et y sur le plan 2D
 */
class PositionComponent {
    constructor({ x, y }) {
        this.x = x || 0;
        this.y = y || 0;
    }
}

/**
 * SizeComponent
 * @description Défini une largeur et une hauteur à l'entité
 */
class SizeComponent {
    constructor({ width, height }) {
        this.width  = width  || 0;
        this.height = height || 0;
    }
}

/**
 * SpeedComponent
 * @description Défini un vecteur directionnel sur le plan 2D
 *              avec des coordonnées x et y
 */
class SpeedComponent {
    constructor({ x, y }) {
        this.x = x || 0;
        this.y = y || 0;
    }
}

/**
 * RenderStrokeRectangleComponent
 * @description Défini une bordure en rectangle avec sa couleur
 *              et sa largeur
 */
class RenderStrokeRectangleComponent {
    constructor({ width, height, lineWidth, color }) {
        if(width)  this.width  = width;
        if(height) this.height = height;

        this.lineWidth = lineWidth || 0;
        this.color     = color || 'rgba(0, 0, 0, 0)';
    }
}

/**
 * RenderFillRectangleComponent
 * @description Défini un rectangle rempli d'une couleur donnée.
 */
class RenderFillRectangleComponent {
    constructor({ width, height, color }) {
        if(width)  this.width  = width;
        if(height) this.height = height;
        
        this.color = color || 'rgba(0, 0, 0, 0)';
    }
}

La logique des composants veux que chacun d’entre eux amène une donnée spécifique qui rentre dans une logique spécifique. Certains semblent être identiques, par exemple Speed et Location, mais pourtant ils ne parlent pas de la même chose. L’un parle d’une position dans le plan et l’autre d’une vitesse dans le plan. C’est donc un fonctionnement plutôt sémantique, on traduit rapidement ce que l’on veut pour une entité avec de minuscules composants.

Chaque nom de Component pourra faire référence à un System, ou pourra être utiliser par un System.
Si vous regardez le RenderFillRectangleComponent, j’ai conditionné la création des attributs width et height car s’ils ne sont pas spécifié, cela dira au System qu’il peut aller prendre le SizeComponent pour les données de largeur et hauteur. On verra ça pendant la construction du system 😉

Testons déjà tout ça :

Maintenant qu’on a tout une procédure de création d’entités, voyons un peu ce que ça donne :

let myEngine = new Engine();

// On défini les composant dont on aura besoin dans le moteur
myEngine.define('Class.Components.Position', PositionComponent);

// Et on créé une entité
myEngine.Managers.EntityManager.createEntity([
    {
        namespace: 'Class.Components.Position',
        parameters: { x: 25, y: 50 }
    }
]);

console.log(myEngine);

En remontant plus loin dans l’objet affiché dans le log, vous verrez dans l’EntityManager, une petite entité ayant pour nom un ID bizarre. Dans cette entité se trouve son _id et un attribut « Position » avec pour valeur un objet contenant un x à 25 et un y à 50.

Pour la suite c’est simple, si je veux rajouter un composant de taille, il me suffit de rajouter le composant dans le tableau de configuration de l’entité comme ceci :

let myEngine = new Engine();

// On défini les composant dont on aura besoin dans le moteur
myEngine.define('Class.Components.Position', PositionComponent);
myEngine.define('Class.Components.Size', SizeComponent);

// Et on créé une entité
myEngine.Managers.EntityManager.createEntity([
    {
        namespace: 'Class.Components.Position',
        parameters: { x: 25, y: 50 }
    },
    {
        namespace: 'Class.Components.Size',
        parameters: { width: 150, height: 25 }
    }
]);

console.log(myEngine);

Il nous est très simple maintenant de développer et ajouter de nouveaux composants pour nos entités !
Vous verrez d’ailleurs que cette petite remise à zéro permettra d’être beaucoup plus organisé grâce à la gestion du namespace de l’Engine.

Prochains articles on verra l’introduction des Systems et l’exploitation des Component.

J’espère que l’article vous aura plu, si vous avez des question, des remarques, suggestions, n’hésitez pas à participer dans les commentaires 😉
Un petit partage de l’article aiderais aussi beaucoup pour faire connaître le site 🙂

Code en entier

/**
 * Engine
 * @description Classe racine du moteur 2D, défini un espace de nom
 */
class Engine {
    constructor() {
        this.define('Managers.EntityManager', new EntityManager(this));
    }

    /**
     * define
     * @description Définie une valeur en fonction d'un namespace donné
     * @param namespace
     * @param value
     */
    define(namespace, value) {
        // On split le namespace a chaque "."
        let parts = namespace.split('.');
        // Définition de l'instance comme racine
        let root  = this;

        // On parcours le namespace
        for(let i in parts) {
            // Tant que le curseur est pas en derniere
            // position on redéfini la racine comme le
            // dernier espace de nom.
            if(i < parts.length - 1) {
                if(!root[parts[i]]) {
                    root[parts[i]] = {};
                }

                root = root[parts[i]];
            } else {
                // Si on est en derniere position
                // on défini la valeur.
                root[parts[i]] = value;
            }
        }
    }

    /**
     * require
     * @description Retourne une valeur selon un namespace
     * @param namespace
     * @returns {Engine}
     */
    require(namespace) {
        // On split le namespace a chaque "."
        let parts = namespace.split('.');
        // Définition de l'instance comme racine
        let root  = this;

        // On parcours le namespace
        for(let i in parts) {
            // Si l'espace de nom est pas connu
            // on renvoie une erreur
            if(!root[parts[i]]) {
                console.error(new Error(`Unknown namespace ${i} in ${namespace}`));
                break;
            }
            else root = root[parts[i]]; // sinon on redéfini la racine
        }

        return root;
    }
}

/**
 * Entity
 * @description Plus petit dénominateur commun des Entités
 */
class Entity {
    constructor(id) {
        this._id = id;
    }
}

/**
 * Entity Builder
 * @description Se charge de recevoir les configuration et les appliquer à
 *              une nouvelle instance d'entité.
 */
class EntityBuilder {
    constructor(Engine, id) {
        // On défini l'Engine Root pour la gestion du require
        this.Engine = Engine;
        // On créé l'instance de l'entité avec l'identifiant fourni
        this.entity = new Entity(id);
    }

    /**
     * require
     * @description voir Require dans Engine
     * @param namespace
     * @returns {*}
     */
    require(namespace) {
        return this.Engine.require(namespace);
    }

    /**
     * addComponent
     * @description Ajoute et configure un composant dans l'entité
     * @param namespace
     * @returns {EntityBuilder}
     */
    addComponent(namespace, parameters) {
        // Récupération de la class de composant
        let component     = this.require(namespace);
        // Si le composant donné est indéfini
        if(!component) return this;
        // Récupération du nom du composant
        let name          = namespace.split('.').pop();
        // Intégration d'une nouvelle instance de composant
        // dans l'entité
        this.entity[name] = new component(parameters);

        return this;
    }

    /**
     * build
     * @description Construit l'entité selon la configuration donnée
     * @param configuration
     * @returns {Entity}
     */
    build(configuration) {
        for(let i in configuration) {
            this.addComponent(configuration[i].namespace, configuration[i].parameters);
        }

        return this.entity;
    }
}

/**
 * Entity Manager
 */
class EntityManager {
    constructor(Engine) {
        this.Engine   = Engine;
        this.entities = [];
    }

    /**
     * require
     * @description Voir require dans Engine
     * @param namespace
     * @returns {Engine|*}
     */
    require(namespace) {
        return this.Engine.require(namespace);
    }

    /**
     * buildEntity
     * @description Défini un ID unique et construit l'entité
     * @param configuration
     * @returns {Entity}
     */
    buildEntity(configuration) {
        /**
         * makeId
         * @description Créé un identifiant en fonction d'une taille
         *              définie
         * @param length
         * @returns {string}
         */
        function makeId(length = 8) {
            let text     = "";
            let possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

            for (let i = 0; i < length; i++)
                text += possible.charAt(Math.floor(Math.random() * possible.length));

            return text;
        }

        // Création d'un identifiant unique
        let id = makeId(16);

        // On créé une mini boucle qui vérifie si l'id est unique
        // Tant que l'id n'est pas unique, il est recréé.
        while(this.entities[id] !== undefined) id = makeId(16);

        // On retourne un nouveau build d'un EntityBuilder.
        return (new EntityBuilder(this.Engine, id)).build(configuration);
    }

    /**
     * createEntity
     * @description Créé une nouvelle entité identifiée de manière
     *              unique et l'intègre dans le tableau d'entités
     * @param configuration
     * @returns {EntityManager}
     */
    createEntity(configuration) {
        // Construit l'entité
        let newEntity = this.buildEntity(configuration);
        // Intègre l'entité dans le tableau des entités
        this.entities[newEntity._id] = newEntity;
        // Retourne l'instance du manager
        return this;
    }
}

/**
 * PositionComponent
 * @description Défini des coordonnées x et y sur le plan 2D
 */
class PositionComponent {
    constructor({ x, y }) {
        this.x = x || 0;
        this.y = y || 0;
    }
}

/**
 * SizeComponent
 * @description Défini une largeur et une hauteur à l'entité
 */
class SizeComponent {
    constructor({ width, height }) {
        this.width  = width  || 0;
        this.height = height || 0;
    }
}

/**
 * SpeedComponent
 * @description Défini un vecteur directionnel sur le plan 2D
 *              avec des coordonnées x et y
 */
class SpeedComponent {
    constructor({ x, y }) {
        this.x = x || 0;
        this.y = y || 0;
    }
}

/**
 * RenderStrokeRectangleComponent
 * @description Défini une bordure en rectangle avec sa couleur
 *              et sa largeur
 */
class RenderStrokeRectangleComponent {
    constructor({ width, height, lineWidth, color }) {
        if(width)  this.width  = width;
        if(height) this.height = height;

        this.lineWidth = lineWidth || 0;
        this.color     = color || 'rgba(0, 0, 0, 0)';
    }
}

/**
 * RenderFillRectangleComponent
 * @description Défini un rectangle rempli d'une couleur donnée.
 */
class RenderFillRectangleComponent {
    constructor({ width, height, color }) {
        if(width)  this.width  = width;
        if(height) this.height = height;

        this.color = color || 'rgba(0, 0, 0, 0)';
    }
}


let myEngine = new Engine();

// On défini les composant dont on aura besoin dans le moteur
myEngine.define('Class.Components.Position', PositionComponent);
myEngine.define('Class.Components.Size', SizeComponent);

// Et on créé une entité
myEngine.Managers.EntityManager.createEntity([
    {
        namespace: 'Class.Components.Position',
        parameters: { x: 25, y: 50 }
    },
    {
        namespace: 'Class.Components.Size',
        parameters: { width: 150, height: 25 }
    }
]);

console.log(myEngine);