11
\$\begingroup\$

I'm creating a JavaScript/WebGL based game and would like you all to take a look at the current entity component system which I created for it. I've used it once before on another project, and while I was very happy with the results, there were parts of it I did not feel 100% good about.

In particular, sometimes a component needs to call the attachedEntities version of a function it has overridden. For example handleInput might call the attachedEntities handleInput functions and then with the new movementVector multiply them by negative 1 so now all input while this component is active is reversed.

When I intercept the properties, I currently wrap intercepted function calls into a closure (see basecomponent.intercept()) so that the this object can refer to the component itself. Without that the this object was still being referred to as the attachedEntity (from within the components version of that function), so to get the components properties I had to run a query.

Things I've used as reference:

Full source code can be found here.

Base Component

(function() {
ChuClone.namespace("ChuClone.components");
ChuClone.components.BaseComponent = function() {
    this.interceptedProperties = {};
    return this;
};

ChuClone.components.BaseComponent.prototype = {
    /**
     * Array of properties intercepted, this is used when detaching the component
     * @type {Array}
     */
    interceptedProperties    : null,
    /**
     * @type {ChuClone.GameEntity}
     */
    attachedEntity            : null,
    /**
     * @type {Number}
     */
    detachTimeout            : 0,
    /**
     * Unique name for this component
     * @type {String}
     */
    displayName                : "BaseComponent",

    /**
     * If a component can stack, then it doesn't matter if it's already attached.
     * If it cannot stack, it is not applied if it's currently active.
     * For example, you can not be frozen after being frozen.
     * However you can be sped up multiple times.
     * @type {Boolean}
     */
    canStack                : false,

    /**
     * Attach the component to the host object
     * @param {ChuClone.GameEntity} anEntity
     */
    attach: function(anEntity) {
        this.attachedEntity = anEntity;
    },

    /**
     * Execute the component
     * For example if you needed to cause an animation to start when a character is 'unfrozen', this is when you would do it
     */
    execute: function() {

    },

    /**
     * Detaches a component from an 'attachedEntity' and restores the properties
     */
    detach: function() {
        clearTimeout(this.detachTimeout);
        this.restore();

        this.interceptedProperties = null;
        this.attachedEntity = null;
    },

    /**
     * Detach after N milliseconds, for example freeze component might call this to unfreeze
     * @param {Number} aDelay
     */
    detachAfterDelay: function(aDelay) {
        var that = this;
        this.detachTimeout = setTimeout(function() {
            that.attachedEntity.removeComponentWithName(that.displayName);
        }, aDelay);
    },

    /**
     * Intercept properties from the entity we are attached to.
     * For example, if we intercept handleInput, then our own 'handleInput' function gets called.
     * We can reset all the properties by calling, this.restore();
     * @param {Array} arrayOfProperties
     */
    intercept: function(arrayOfProperties) {
        var len = arrayOfProperties.length;
        var that = this;
        while (len--) {
            var aKey = arrayOfProperties[len];
            this.interceptedProperties[aKey] = this.attachedEntity[aKey];

            // Wrap function calls in closure so that the 'this' object refers to the component, if not just overwrite
            if(this.attachedEntity[aKey] instanceof Function) {
                this.attachedEntity[aKey] = function(){
                    that[aKey].apply(that, arguments);
                }
            } else {
                this.attachedEntity[aKey] = this[aKey];
            }

        }
    },

    /**
     * Restores poperties that were intercepted that were intercepted.
     * Be sure to call this when removing the component!
     */
    restore: function() {
        for (var key in this.interceptedProperties) {
            if (this.interceptedProperties.hasOwnProperty(key)) {
                this.attachedEntity[key] = this.interceptedProperties[key];
            }
        }
    }
}
 })();

JumpComponent

   (function(){
    ChuClone.namespace("ChuClone.components");

    ChuClone.components.JumpPadComponent = function() {
        ChuClone.components.JumpPadComponent.superclass.constructor.call(this);
        console.log(this)
    };

    ChuClone.components.JumpPadComponent.prototype = {
        displayName                     : "JumpPadComponent",                   // Unique string name for this Trait

        _textureSource                  : "assets/images/game/jumppad.png",
        _restitution                    : 2,
        _previousMaterial               : null,
        _previousRestitution            : 0,

        /**
         * @inheritDoc
         */
        attach: function(anEntity) {
            ChuClone.components.JumpPadComponent.superclass.attach.call(this, anEntity);

            var view = anEntity.getView();
            var body = anEntity.getBody();

            // Swap materials
            this._previousMaterial = view.materials[0];
            view.materials[0] = new THREE.MeshLambertMaterial( {
                color: 0xFFFFFF, shading: THREE.SmoothShading,
                map : THREE.ImageUtils.loadTexture( this._textureSource )
            });

            view.materials[0] = new THREE.MeshBasicMaterial( { color: 0x608090, opacity: 0.5, wireframe: true } );

            // Swap restitution
            this.swapRestitution( body );


            // Listen for body change
            this.intercept(['setBody', 'height']);
        },

        /**
         * Sets the restitution level of  the provided body's fixtures to make it a jumppad
         * @param {Box2D.Dynamics.b2Body} aBody
         */
        swapRestitution: function( aBody ) {
            var node = aBody.GetFixtureList();
            while(node) {
                var fixture = node;
                node = fixture.GetNext();

                this._previousRestitution = fixture.GetRestitution();
                fixture.SetRestitution( this._restitution );
            }
        },

        /**
         * Set the body
         * @param {Box2D.Dynamics.b2Body} aBody
         */
        setBody: function( aBody ) {
            this.interceptedProperties.setBody.call(this.attachedEntity, aBody );
            if(aBody) // Sometimes setBody is called with null
                this.swapRestitution( aBody )
        },

        /**
         * Restore material and restitution
         */
        detach: function() {
            this.attachedEntity.getView().materials[0] = this._previousMaterial;

            var node = this.attachedEntity.getBody().GetFixtureList();
            while(node) {
                var fixture = node;
                node = fixture.GetNext();
                fixture.SetRestitution(this._previousRestitution);
            }

            ChuClone.components.JumpPadComponent.superclass.detach.call(this);
        }

    };

    ChuClone.extend( ChuClone.components.JumpPadComponent, ChuClone.components.BaseComponent );
})();

Entity support for components

    /**
     * Adds and attaches a component, to this entity
     * @param {ChuClone.components.BaseComponent}  aComponent
     * @return {ChuClone.components.BaseComponent}
     */
    addComponent: function(aComponent) {
        // Check if we already have this component, if we do - make sure the component allows stacking
        var existingVersionOfComponent = this.getComponentWithName(aComponent.displayName);
        if (existingVersionOfComponent && !existingVersionOfComponent.canStack) {
            return false;
        }

        // Remove existing version
        if (existingVersionOfComponent) {
            this.removeComponentWithName(aComponent.displayName);
        }


        this.components.push(aComponent);
        aComponent.attach(this);

        return aComponent;
    },

    /**
     * Convenience method that calls ChuClone.GameEntity.addComponent then calls execute on the newly created component
     * @param {ChuClone.components.BaseComponent}  aComponent
     * @return {ChuClone.components.BaseComponent}
     */
    addComponentAndExecute: function(aComponent) {
        var wasAdded = this.addComponent(aComponent);
        if (wasAdded) {
            aComponent.execute();
            return aComponent;
        }

        return null;
    },

    /**
     * Returns a component with a matching .displayName property
     * @param aComponentName
     */
    getComponentWithName: function(aComponentName) {
        var len = this.components.length;
        var component = null;
        for (var i = 0; i < len; ++i) {
            if (this.components[i].displayName === aComponentName) {
                component = this.components[i];
                break;
            }
        }
        return component;
    },

    /**
     * Removes a component with a matching .displayName property
     * @param {String}  aComponentName
     */
    removeComponentWithName: function(aComponentName) {
        var len = this.components.length;
        var removedComponents = [];
        for (var i = 0; i < len; ++i) {
            if (this.components[i].displayName === aComponentName) {
                removedComponents.push(this.components.splice(i, 1));
                break;
            }
        }

        // Detach removed components
        if (removedComponents) {
            i = removedComponents.length;
            while (i--) {
                removedComponents[i].detach();
            }
        }
    },

    /**
     * Removes all components contained in this entity
     */
    removeAllComponents: function() {
        var i = this.components.length;
        while (i--) {
            this.components[i].detach();
        }

        this.components = [];
    }

Usage

var jumpPadComponent = new ChuClone.components.JumpPadComponent();
entity.addComponentAndExecute( jumpPadComponent );

//... Some time later
entity.removeComponentWithName( ChuClone.components.JumpPadComponent.prototype.displayName );  

I'm curious about others' thoughts on this implementation of such a component-based system for a JavaScript game.

JumpPadComponent attached to a couple of standard entities:

JumpPad component toggle

\$\endgroup\$
  • 2
    \$\begingroup\$ "i did not feel 100% about. <code dump>" Would you like to give a high level overview of which parts might need addressing \$\endgroup\$ – Raynos Jul 4 '11 at 20:35
  • 2
    \$\begingroup\$ A rough review of the code makes me think it's far too verbose and over-engineered. KISS. \$\endgroup\$ – Raynos Jul 4 '11 at 20:46
  • \$\begingroup\$ Well that isn't much help, in fact you had already posted that before I finished posting the entire code dump - specifically the management of components within the entity. Which was one of the more important parts ot it. \$\endgroup\$ – onedayitwillmake Jul 4 '11 at 21:13
5
\$\begingroup\$

Your code requires a ton of domain knowledge to properly review. I went to your Github repository to try and be more familiar with what you have.

Entity support for components

You asked specifically for this one, and I did find some things to ponder upon.

addComponent

  • existingVersionOfComponent -> a better name could be existingInstanceofComponent or even simpler instance, as there is no versioning.

  • // Remove existing version, for me it is confusing that the component can stack, but you still only allow 1 instance of it. Maybe you should find a better name/descriptor than stack.

addComponentAndExecute

  • You return null here , not bad in itself, except that you return false in addComponent. You might want to consider reviewing your code and standardizing on false or null.

removeComponentWithName

  • This code confuses me. It seems built with multiple instances of the same component in mind, but then it breaks out the for loop. It seems you could write this code with 1 loop.

  • You can merge the 2 loops here in to 1 loop:

    removeComponentWithName: function(aComponentName) {
        var len = this.components.length;
        var removedComponents = [];
        for (var i = 0; i < len; ++i) {
            if (this.components[i].displayName === aComponentName) {
                removedComponents.push(this.components.splice(i, 1));
                break;
            }
        }
    
        // Detach removed components
        if (removedComponents) {
            i = removedComponents.length;
            while (i--) {
                removedComponents[i].detach();
            }
        }
    },
    

    can be

    removeComponentWithName: function(componentName) {
        var len = this.components.length,
            removedComponent;
        for (var i = 0; i < len; ++i) {
            if (this.components[i].displayName === componentName) {
                removedComponent = this.components.splice(i, 1).pop();
                removedComponent.detach();
                break;
            }
        }
    },
    
\$\endgroup\$
  • 1
    \$\begingroup\$ Great feedback. The get component with name, uses the string, displayName property of a component - because it looks for instances of that type, instead of a specific component. \$\endgroup\$ – onedayitwillmake Jan 28 '14 at 1:27
  • \$\begingroup\$ Right, it can be called by other components with no specific instance in mind. For example a slowdown component, will just get a component called 'speedup' by name and disable it. Not sure what your question is then, obviously \$\endgroup\$ – onedayitwillmake Jan 28 '14 at 2:05
  • \$\begingroup\$ Removed that part. \$\endgroup\$ – konijn Jan 28 '14 at 13:09

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.