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:
- http://cowboyprogramming.com/2007/01/05/evolve-your-heirachy/
- http://gameprogrammingpatterns.com/component.html
- http://blog.bengarney.com/2010/06/12/composition-vs-inheritance/
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: