├── src ├── index.js ├── performance.js ├── utils.js ├── system.js ├── uid.js ├── ecs.js └── entity.js ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── test ├── unit │ ├── entity.test.js │ ├── system.test.js │ ├── uid.test.js │ └── ecs.test.js ├── .eslintrc.json └── karma.conf.js ├── webpack.config.js ├── .travis.yml ├── package.json ├── LICENSE └── README.md /src/index.js: -------------------------------------------------------------------------------- 1 | import ECS from './ecs'; 2 | 3 | // exports as root. 4 | module.exports = ECS; 5 | -------------------------------------------------------------------------------- /src/performance.js: -------------------------------------------------------------------------------- 1 | let perf = typeof window !== 'undefined' ? window.performance : null; 2 | 3 | // polyfill for browser performance module 4 | if (!perf) { 5 | const start = Date.now(); 6 | 7 | perf = { 8 | now() { 9 | return Date.now() - start; 10 | }, 11 | }; 12 | } 13 | 14 | export default perf; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs. 2 | # More information at http://EditorConfig.org 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [{package.json,bower.json,*.yml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@englercj/code-style/.eslintrc.json", 3 | "env": { 4 | "commonjs": true, 5 | "browser": true 6 | }, 7 | "globals": { 8 | }, 9 | "parserOptions": { 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "curly": [2, "multi-line"], 14 | "no-magic-numbers": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # sublime text files 2 | *.sublime* 3 | *.*~*.TMP 4 | 5 | # temp files 6 | .DS_Store 7 | Thumbs.db 8 | Desktop.ini 9 | npm-debug.log 10 | 11 | # project files 12 | .project 13 | .idea 14 | 15 | # vim swap files 16 | *.sw* 17 | 18 | # emacs temp files 19 | *~ 20 | \#*# 21 | 22 | # project ignores 23 | !.gitkeep 24 | *__temp 25 | *.sqlite 26 | .snyk 27 | .commit 28 | node_modules/ 29 | dist/ 30 | -------------------------------------------------------------------------------- /test/unit/entity.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Entity = ECS.Entity; 4 | 5 | describe('Entity', function () { 6 | it('should initialize', function () { 7 | var entity = new Entity(); 8 | 9 | expect(entity.id).to.be.a('number'); 10 | }); 11 | 12 | it('should have an unique id', function () { 13 | var entity1 = new Entity(); 14 | var entity2 = new Entity(); 15 | 16 | expect(entity1.id).to.be.not.equal(entity2.id); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc.json", 3 | "env": { 4 | "commonjs": true, 5 | "mocha": true 6 | }, 7 | "globals": { 8 | "chai": false, 9 | "expect": false, 10 | "sinon": false, 11 | "ECS": false 12 | }, 13 | "parserOptions": { 14 | "sourceType": "script" 15 | }, 16 | "rules": { 17 | "strict": 0, 18 | "no-unused-expressions": 0, 19 | "func-names": 0, 20 | "no-var": 0 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const path = require('path'); 5 | const webpack = require('webpack'); 6 | 7 | module.exports = { 8 | entry: path.join(__dirname, 'src', 'index.js'), 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | library: 'ECS', 12 | libraryTarget: 'umd', 13 | umdNamedDefine: true, 14 | }, 15 | module: { 16 | loaders: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | loaders: [ 21 | 'babel?cacheDirectory=true&presets[]=es2015-loose', 22 | ], 23 | }, 24 | ], 25 | }, 26 | plugins: [ 27 | // don't emit output when there are errors 28 | new webpack.NoErrorsPlugin(), 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A faster version of bind. 3 | * 4 | * @param {*} thisArg - The context to call the function with. 5 | * @param {function} methodFunc - The function to bind. 6 | * @return {function} A bound version of the function. 7 | */ 8 | export function fastBind(thisArg, methodFunc) { 9 | return function _boundFunction() { 10 | methodFunc.apply(thisArg, arguments); // eslint-disable-line prefer-rest-params 11 | }; 12 | } 13 | 14 | /** 15 | * A faster version of splice. 16 | * 17 | * @param {*[]} array - The array to remove from 18 | * @param {number} startIndex - Index to start removal 19 | * @param {number} removeCount - The number of elements to remove. 20 | */ 21 | export function fastSplice(array, startIndex, removeCount) { 22 | const len = array.length; 23 | 24 | if (startIndex >= len || removeCount === 0) { 25 | return; 26 | } 27 | 28 | removeCount = startIndex + removeCount > len ? (len - startIndex) : removeCount; 29 | 30 | const removeLen = len - removeCount; 31 | 32 | for (let i = startIndex; i < len; i += 1) { 33 | array[i] = array[i + removeCount]; 34 | } 35 | 36 | array.length = removeLen; 37 | } 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | 5 | node_js: 6 | - '6' 7 | 8 | branches: 9 | only: 10 | - master 11 | 12 | cache: 13 | directories: 14 | - node_modules 15 | 16 | before_script: 17 | - export DISPLAY=:99.0 18 | - sh -e /etc/init.d/xvfb start 19 | 20 | install: 21 | - npm config set spin false 22 | - npm config set loglevel http 23 | - npm install 24 | 25 | script: 26 | - npm test 27 | 28 | addons: 29 | sauce_connect: true 30 | 31 | env: 32 | global: 33 | - SAUCE_USERNAME=faejs 34 | - secure: "ZsvHzpG2Fn1PB08mSXTXr+Z5K3UszmlPzBJ1F0YkdFKvMLhPfjaEgWkFVELSHWPcM277HHCHjUXuKBGTVOBCdBPQly2oqmXEBXOqtkdR2BMIIPlW2m6GE8iIEPR1Sm8t/uxWQp//nBwhX5rW6RRByHJm9mzoXg9dGSeqM1+ZEKIh7kNWd5kmAy8xNd0dcP7wsGmm+5nxpmTrbQf+IXCKxVl5twJ9k+F+uUTMsotaybpcmyFbkYURG7lhEuRJKUJwnnjYRGvpklKFGUhbqG3A3LyrnnJOIK+awAnVnRUL21HssC+NW7/NSkOtGmWZUhzyJ4z92EPAIvtWp1JGJGpIF6D7sE0b/yd5n+xIX9U1pRbEYZM6BhPXdJMdUvK2O+v7kfMpUWrtaN56jhFk3plgINy+iGStw5151O8XPb3bwnT3WCNJkP7lxUGHgOaxJ9modzLEw6A6gNO84JpN/ATyLVMqLBI50oNmNj+13+gy0mOxBACBL8hZCUpqMJMUTY6bKO/QxUmS9kLdi5EztkPwWoUplrG0jZ9nen5rXke6zT1nSFj5bzxMmU7pFe29XSoFo0nCvzZvD1rGEmIspe2DU+FpT71zr3qc5tppvxBEYaTbJpgbsfKyMV+kHG1Y+dyvqkjuCTmaC7v7+mIP0rMyiII1rblCBgA0m/JmipWekoc=" 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fae/ecs", 3 | "version": "1.0.2", 4 | "description": "Entity Component System used in the Fae rendering engine", 5 | "author": "Chad Engler ", 6 | "license": "MIT", 7 | "main": "dist/ecs.js", 8 | "contributors": [ 9 | "Pierre Beaujeu" 10 | ], 11 | "homepage": "https://github.com/Fae/ecs#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Fae/ecs.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/Fae/ecs/issues" 18 | }, 19 | "keywords": [ 20 | "game", 21 | "gamedev", 22 | "ecs", 23 | "entity component system", 24 | "design pattern" 25 | ], 26 | "directories": { 27 | "lib": "./src" 28 | }, 29 | "files": [ 30 | "src/", 31 | "dist/" 32 | ], 33 | "scripts": { 34 | "clean": "rm -rf dist/", 35 | "build": "webpack -p --config webpack.config.js --output-filename ecs.min.js --progress", 36 | "dev": "webpack -d --config webpack.config.js --output-filename ecs.js --progress --colors", 37 | "watch": "npm run dev -- --watch", 38 | "lint": "eslint src/ test/", 39 | "start": "npm run build", 40 | "test": "npm run lint && npm run dev && npm run test-dev -- --single-run", 41 | "test-ci": "npm run test-dev -- --single-run", 42 | "test-dev": "karma start test/karma.conf.js", 43 | "prepublish": "npm run build && npm run dev" 44 | }, 45 | "devDependencies": { 46 | "@englercj/code-style": "^1.0.6", 47 | "babel-core": "^6.13.2", 48 | "babel-loader": "^6.2.4", 49 | "babel-preset-es2015": "^6.13.2", 50 | "babel-preset-es2015-loose": "^7.0.0", 51 | "chai": "^3.5.0", 52 | "eslint": "^3.2.2", 53 | "karma": "^1.1.2", 54 | "karma-chrome-launcher": "^1.0.1", 55 | "karma-firefox-launcher": "^1.0.0", 56 | "karma-mocha": "^1.1.1", 57 | "karma-mocha-reporter": "^2.1.0", 58 | "karma-sauce-launcher": "^1.0.0", 59 | "karma-sinon-chai": "^1.2.3", 60 | "mocha": "^3.0.2", 61 | "sinon": "^1.17.5", 62 | "sinon-chai": "^2.8.0", 63 | "webpack": "^1.13.1" 64 | }, 65 | "dependencies": { 66 | "core-js": "^2.4.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/unit/system.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var System = ECS.System; 4 | 5 | function getFakeEntity() { 6 | return { 7 | _addSystem: sinon.spy(), 8 | _removeSystem: sinon.spy(), 9 | }; 10 | } 11 | 12 | describe('System', function () { 13 | it('should initialize', function () { 14 | var system = new System(); 15 | 16 | expect(system).to.exist; 17 | }); 18 | 19 | describe('addEntity()', function () { 20 | var entity = null; 21 | var system = null; 22 | 23 | beforeEach(function () { 24 | entity = getFakeEntity(); 25 | system = new System(); 26 | }); 27 | 28 | it('should add an entity to the system', function () { 29 | system.addEntity(entity); 30 | 31 | expect(system.entities.length).to.be.equal(1); 32 | }); 33 | 34 | it('should add the system to entity systems', function () { 35 | system.addEntity(entity); 36 | 37 | expect(entity._addSystem.calledWith(system)).to.be.equal(true); 38 | }); 39 | 40 | it('should call enter() on added entity', function () { 41 | system.enter = sinon.spy(); 42 | 43 | system.addEntity(entity); 44 | 45 | expect(system.enter.calledWith(entity)).to.be.equal(true); 46 | }); 47 | }); 48 | 49 | describe('removeEntity()', function () { 50 | var entity = null; 51 | var system = null; 52 | 53 | beforeEach(function () { 54 | entity = getFakeEntity(); 55 | system = new System(); 56 | 57 | system.addEntity(entity); 58 | }); 59 | 60 | it('should remove an entity from the system', function () { 61 | system.removeEntity(entity); 62 | 63 | expect(system.entities.length).to.be.equal(0); 64 | }); 65 | 66 | it('should remove the system from entity systems', function () { 67 | system.removeEntity(entity); 68 | 69 | expect(entity._removeSystem.calledWith(system)).to.be.equal(true); 70 | }); 71 | 72 | it('should call exit() on removed entity', function () { 73 | system.exit = sinon.spy(); 74 | 75 | system.removeEntity(entity); 76 | 77 | expect(system.exit.calledWith(entity)).to.be.equal(true); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Chad Engler 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | ------------------------------- 22 | 23 | The original code this library is based on was written by Pierre Beaujeu and 24 | licensed under the MIT license. Below is the original license. 25 | 26 | Copyright (c) 2015 Pierre BEAUJEU 27 | 28 | Permission is hereby granted, free of charge, to any person obtaining a copy of 29 | this software and associated documentation files (the "Software"), to deal in 30 | the Software without restriction, including without limitation the rights to 31 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 32 | of the Software, and to permit persons to whom the Software is furnished to do 33 | so, subject to the following conditions: 34 | 35 | The above copyright notice and this permission notice shall be included in all 36 | copies or substantial portions of the Software. 37 | 38 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 39 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 40 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 41 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 42 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 43 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 44 | SOFTWARE. 45 | 46 | -------------------------------------------------------------------------------- /test/unit/uid.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var uid = ECS.uid; 4 | 5 | describe('uid', function () { 6 | it('should have a default generator', function () { 7 | expect(uid.DefaultUIDGenerator).to.exist; 8 | }); 9 | 10 | it('should create a new generator', function () { 11 | var gen = new uid.UIDGenerator(); 12 | 13 | expect(gen.salt).to.be.a('number'); 14 | expect(gen.uidCounter).to.be.equal(0); 15 | }); 16 | 17 | it('should return sequential unique ids', function () { 18 | var gen = new uid.UIDGenerator(); 19 | var r1 = gen.next(); 20 | var r2 = gen.next(); 21 | 22 | expect(r1).to.be.a('number'); 23 | expect(r2).to.be.a('number'); 24 | expect(r1).to.be.not.equal('r2'); 25 | }); 26 | 27 | it('should return different sequences with different salts', function () { 28 | var gen1 = new uid.UIDGenerator(1); 29 | var gen2 = new uid.UIDGenerator(2); 30 | 31 | var r11 = gen1.next(); 32 | var r12 = gen1.next(); 33 | var r21 = gen2.next(); 34 | var r22 = gen2.next(); 35 | 36 | expect(r11).to.be.a('number'); 37 | expect(r12).to.be.a('number'); 38 | expect(r21).to.be.a('number'); 39 | expect(r22).to.be.a('number'); 40 | 41 | expect(r11).to.be.not.equal(r21); 42 | expect(r12).to.be.not.equal(r22); 43 | }); 44 | 45 | it('should return generator with incremented salts when calling nextGenerator()', function () { 46 | var gen1 = uid.nextGenerator(); 47 | var gen2 = uid.nextGenerator(); 48 | 49 | expect(gen1.salt).to.be.a('number').and.to.be.not.equal(gen2.salt); 50 | }); 51 | 52 | it('should return incremented salts when calling nextSalt()', function () { 53 | var salt1 = uid.nextSalt(); 54 | var salt2 = uid.nextSalt(); 55 | 56 | expect(salt1).to.be.a('number').and.to.be.not.equal(salt2); 57 | }); 58 | 59 | describe('isSaltedBy()', function () { 60 | it('should return true when then id was salted with given salt', function () { 61 | var gen1 = new uid.UIDGenerator(1); 62 | var gen2 = new uid.UIDGenerator(2); 63 | 64 | var r1 = gen1.next(); 65 | var r2 = gen2.next(); 66 | 67 | expect(uid.isSaltedBy(r1, 1)).to.be.equal(true); 68 | expect(uid.isSaltedBy(r2, 1)).to.be.equal(false); 69 | expect(uid.isSaltedBy(r2, 2)).to.be.equal(true); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/system.js: -------------------------------------------------------------------------------- 1 | import { fastSplice } from './utils'; 2 | 3 | /** 4 | * A system update all eligible entities at a given frequency. 5 | * This class is not meant to be used directly and should be sub-classed to 6 | * define specific logic. 7 | * 8 | * @class 9 | * @alias ECS.System 10 | */ 11 | export default class System { 12 | /** 13 | * 14 | * @param {number} frequency Frequency of execution. 15 | */ 16 | constructor(frequency = 1) { 17 | /** 18 | * Frequency of update execution, a frequency of `1` run the system every 19 | * update, `2` will run the system every 2 updates, ect. 20 | * 21 | * @member {number} 22 | */ 23 | this.frequency = frequency; 24 | 25 | /** 26 | * Entities of the system. 27 | * 28 | * @member {Entity[]} 29 | */ 30 | this.entities = []; 31 | 32 | /** 33 | * Flag that tells the ECS to actually use this system. Since adding/removing 34 | * a system can be expensive, this is a way to temporarily disable a system 35 | * without taking the cost of recalculating the eligibility of all the entities. 36 | * 37 | * @member {boolean} 38 | */ 39 | this.enable = true; 40 | } 41 | 42 | /** 43 | * Add an entity to the system entities. 44 | * 45 | * @param {Entity} entity - The entity to add to the system. 46 | */ 47 | addEntity(entity) { 48 | entity._addSystem(this); 49 | this.entities.push(entity); 50 | 51 | this.enter(entity); 52 | } 53 | 54 | /** 55 | * Remove an entity from the system entities. exit() handler is executed 56 | * only if the entity actually exists in the system entities. 57 | * 58 | * @param {Entity} entity - Reference of the entity to remove. 59 | */ 60 | removeEntity(entity) { 61 | const index = this.entities.indexOf(entity); 62 | 63 | if (index !== -1) { 64 | entity._removeSystem(this); 65 | fastSplice(this.entities, index, 1); 66 | 67 | this.exit(entity); 68 | } 69 | } 70 | 71 | /** 72 | * Initialize the system. This is called when the system is added 73 | * to the ECS manager. 74 | * 75 | */ 76 | initialize() {} // eslint-disable-line no-empty-function 77 | 78 | /** 79 | * Dispose the system by exiting all the entities. This is called 80 | * when the system is removed from the ECS manager. 81 | * 82 | */ 83 | dispose() { 84 | for (let i = 0; i < this.entities.length; ++i) { 85 | this.entities[i]._removeSystem(this); 86 | this.exit(this.entities[i]); 87 | } 88 | } 89 | 90 | /** 91 | * Abstract method to subclass. Should return true if the entity is eligible 92 | * to the system, false otherwise. 93 | * 94 | * @param {Entity} entity - The entity to test. 95 | * @return {boolean} True if entity should be included. 96 | */ 97 | test(entity) { // eslint-disable-line no-unused-vars 98 | return false; 99 | } 100 | 101 | /** 102 | * Abstract method to subclass. Called when an entity is added to the system. 103 | * 104 | * @param {Entity} entity - The added entity. 105 | */ 106 | enter(entity) {} // eslint-disable-line no-empty-function,no-unused-vars 107 | 108 | /** 109 | * Abstract method to subclass. Called when an entity is removed from the system. 110 | * 111 | * @param {Entity} entity - The removed entity. 112 | */ 113 | exit(entity) {} // eslint-disable-line no-empty-function,no-unused-vars 114 | 115 | /** 116 | * Abstract method to subclass. Called for each entity to update. This is 117 | * the only method that should actual mutate entity state. 118 | * 119 | * @param {Entity} entity - The entity to update. 120 | * @param {number} elapsed - The time elapsed since last update call. 121 | */ 122 | update(entity, elapsed) {} // eslint-disable-line no-empty-function,no-unused-vars 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Entity Component System 2 | 3 | [![Build Status](https://travis-ci.org/Fae/ecs.svg?branch=master)](https://travis-ci.org/Fae/ecs) 4 | 5 | This library implements the entity-component-system pattern in EcmaScript6. This library 6 | was originally based on [yagl/ecs](https://github.com/yagl/ecs), but has been modified 7 | for performance and a focus on assemblages and mixins. 8 | 9 | In this ECS implementation components are not only dumb data containers, but are full 10 | mixins that can add functionality to an entity. The method of creating a prototype chain 11 | based on the mixins was derived from [this article][mixins]. Maybe this is more of an 12 | Entity-Mixin-System (EMS)... 13 | 14 | ## Features 15 | 16 | * **ES6**: The library is written entirely in ES6. 17 | * **Flexible**: You can subclass the Entity or UIDGenerator classes to implement your 18 | own logic. 19 | * **Fast**: Intelligently batches entities and systems so that the minimum amount 20 | of time is spent on pure iteration. 21 | - Since the eligibility to systems is computed only when the components list 22 | change, and in most cases the overhead of systems eligibility will be computed once 23 | per entity, when added. Therefore there is no overhead for most iterations. 24 | [Iteration is often considered as a flaw of ecs pattern](https://en.wikipedia.org/wiki/Entity_component_system#Drawbacks). 25 | 26 | ## Getting started 27 | 28 | Here is a "minimalist" example of what you can do with this library: 29 | 30 | ```js 31 | import ECS from '@fae/ecs'; 32 | 33 | // components definitions, a component is a "subclass factory". 34 | // That is, a function that takes a base object to extend and 35 | // returns a class that extends that base. 36 | const PositionComponent = (Base) => class extends Base { 37 | constructor() { 38 | // entities may have ctor args, so always pass 39 | // along ctor args in a component. 40 | super(...arguments); 41 | 42 | this._x = 0; 43 | this._y = 0; 44 | } 45 | 46 | get x() { return this._x; } 47 | set x(v) { this._x = Math.floor(v); } 48 | 49 | get y() { return this._y; } 50 | set y(v) { this._y = Math.floor(v); } 51 | } 52 | 53 | const TextureComponent = (Base) => class extends Base { 54 | constructor() { 55 | super(...arguments); // pass along ctor args 56 | 57 | this.texture = new Image(); 58 | } 59 | } 60 | 61 | // you can extend ECS.Entity to create a component assemblage 62 | class Sprite extends ECS.Entity.with(PositionComponent, TextureComponent) { 63 | constructor(imageUrl) { 64 | super(); 65 | 66 | this.texture.src = imageUrl; 67 | } 68 | } 69 | 70 | // you could even extend an assemblage to create a new one that has all the 71 | // components of the parnet: 72 | class SpecializedSprite extends Sprite.with(SpecialComponent) {} 73 | 74 | // render system draws objects with texture and position components 75 | class RenderSystem extends ECS.System { 76 | constructor(ctx) { 77 | this.ctx = ctx; 78 | } 79 | 80 | // only handle entities with a position and a texture 81 | test(entity) { 82 | return entity.hasComponents(PositionComponent, TextureComponent); 83 | } 84 | 85 | // called by the ECS in your update loop 86 | update(entity) { 87 | this.ctx.drawImage(entity.texture, entity.x, entity.y); 88 | } 89 | } 90 | 91 | // main demo application 92 | const canvas = document.getElementById('renderer'); 93 | const ctx = canvas.getContext('2d'); 94 | const ecs = new ECS(); 95 | 96 | // add the system. you can do this at any time since adding/removing a system 97 | // to the ECS will take into account existing entities 98 | ecs.addSystem(new RenderingSystem(ctx)); 99 | 100 | // and add entities, again you can do this at any time. 101 | ecs.addEntity(new Sprite('/img/something.png')); 102 | 103 | // start the game loop 104 | (function update() { 105 | requestAnimationFrame(update); 106 | 107 | canvas.clearRect(0, 0, canvas.width, canvas.height); 108 | 109 | // iterates all the systems and calls update() for each entity 110 | // within the system. 111 | ecs.update(); 112 | })(); 113 | ``` 114 | 115 | 116 | [mixins]: http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/ 117 | -------------------------------------------------------------------------------- /src/uid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * UIDGenerator for multi-instance Entity Component System 3 | * Generate numeric unique ids for ECS entities. The requirements are: 4 | * * generate Numbers for fast comparaison, low storage and bandwidth usage 5 | * * generators can be salted so you can use multiple generators with 6 | * uniqueness guaranty 7 | * * each salted generator can generate reasonable amount of unique ids 8 | */ 9 | 10 | // maximum number of salted generators that can run concurently, once the 11 | // number of allowed generators has been reached the salt of the next 12 | // generator is silently reset to 0 13 | const MAX_SALTS = 10000; 14 | 15 | const MAX_ENTITY_PER_GENERATOR = Math.floor(Number.MAX_SAFE_INTEGER / MAX_SALTS) - 1; 16 | let currentSalt = 0; 17 | 18 | /** 19 | * Generate unique sequences of Numbers. Can be salted (up to 9999 salts) 20 | * to generate differents ids. 21 | * 22 | * To work properly, ECS needs to associate an unique id with each entity. But 23 | * to preserve efficiency, the unique id must be a Number (more exactly a safe 24 | * integer). 25 | * 26 | * The basic implementation would be an incremented Number to generate a unique 27 | * sequence, but this fails when several ecs instances are running and creating 28 | * entities concurrently (e.g. in a multiplayer networked game). To work around 29 | * this problem, ecs provide UIDGenerator class which allow you to salt your 30 | * generated ids sequence. Two generators with different salts will NEVER 31 | * generate the same ids. 32 | * 33 | * Currently, there is a maxumum of 9999 salts and about 900719925473 uid per 34 | * salt. These limits are hard-coded, but I plan to expose these settings in 35 | * the future. 36 | * 37 | * @class 38 | */ 39 | class UIDGenerator { 40 | /** 41 | * 42 | * @param {number} [salt=0] The salt to use for this generator. Number 43 | * between 0 and 9999 (inclusive). 44 | */ 45 | constructor(salt = 0) { 46 | /** 47 | * The salt of this generator. 48 | * 49 | * @member {number} 50 | */ 51 | this.salt = salt; 52 | 53 | /** 54 | * The counter used to generate unique sequence. 55 | * 56 | * @member {number} 57 | */ 58 | this.uidCounter = 0; 59 | } 60 | 61 | /** 62 | * Create a new unique id. 63 | * 64 | * @return {number} An unique id. 65 | */ 66 | next() { 67 | const nextUid = this.salt + (this.uidCounter * MAX_SALTS); 68 | 69 | // if we exceed the number of maximum entities (which is 70 | // very high) reset the counter. 71 | if (++this.uidCounter >= MAX_ENTITY_PER_GENERATOR) { 72 | this.uidCounter = 0; 73 | } 74 | 75 | return nextUid; 76 | } 77 | } 78 | 79 | /** 80 | * @namespace 81 | * @name ECS.uid 82 | */ 83 | const UID = { 84 | /** 85 | * A reference to UIDGenerator class. 86 | * 87 | * @property {class} UIDGenerator 88 | */ 89 | UIDGenerator, 90 | 91 | /** 92 | * The default generator to use if an entity is created without id or generator instance. 93 | * 94 | * @property {UIDGenerator} DefaultUIDGenerator 95 | */ 96 | DefaultUIDGenerator: new UIDGenerator(currentSalt++), 97 | 98 | /** 99 | * Return true if the entity id was salted by given salt 100 | * 101 | * @param {string} entityId Entity id to test 102 | * @param {string} salt Salt to test 103 | * @return {boolean} true if the id was generated by the salt, false 104 | * otherwise 105 | */ 106 | isSaltedBy: (entityId, salt) => entityId % MAX_SALTS === salt, 107 | 108 | /** 109 | * Return the next unique salt. 110 | * 111 | * @method nextSalt 112 | * @return {number} A unique salt. 113 | */ 114 | nextSalt: () => { 115 | const salt = currentSalt; 116 | 117 | // if we exceed the number of maximum salts, silently reset 118 | // to 1 (since 0 will always be the default generator) 119 | if (++currentSalt > MAX_SALTS - 1) { 120 | currentSalt = 1; 121 | } 122 | 123 | return salt; 124 | }, 125 | 126 | /** 127 | * Create a new generator with unique salt. 128 | * 129 | * @method nextGenerator 130 | * @return {UIDGenerator} The created UIDGenerator. 131 | */ 132 | nextGenerator: () => new UIDGenerator(UID.nextSalt()), 133 | }; 134 | 135 | export default UID; 136 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function conf(config) { 5 | config.set({ 6 | basePath: '../', 7 | frameworks: ['mocha', 'sinon-chai'], 8 | autoWatch: true, 9 | logLevel: config.LOG_INFO, 10 | logColors: true, 11 | reporters: ['mocha'], 12 | browsers: ['Chrome'], 13 | browserDisconnectTimeout: 10000, 14 | browserDisconnectTolerance: 2, 15 | browserNoActivityTimeout: 30000, 16 | 17 | sauceLabs: { 18 | testName: 'Fae/ecs', 19 | startConnect: true, 20 | }, 21 | 22 | files: [ 23 | // our code 24 | 'dist/ecs.js', 25 | 26 | // fixtures 27 | // { 28 | // pattern: 'test/fixtures/**/*.js', 29 | // included: true, 30 | // }, 31 | 32 | // tests 33 | { 34 | pattern: `test/unit/**/*.test.js`, 35 | served: true, 36 | included: true, 37 | watched: true, 38 | }, 39 | ], 40 | 41 | plugins: [ 42 | 'karma-mocha', 43 | 'karma-sinon-chai', 44 | 'karma-mocha-reporter', 45 | 'karma-chrome-launcher', 46 | 'karma-firefox-launcher', 47 | 'karma-sauce-launcher', 48 | ], 49 | 50 | customLaunchers: { 51 | /* eslint-disable camelcase */ 52 | SL_Chrome: { 53 | base: 'SauceLabs', 54 | browserName: 'chrome', 55 | version: '35', 56 | }, 57 | SL_Firefox: { 58 | base: 'SauceLabs', 59 | browserName: 'firefox', 60 | version: '30', 61 | }, 62 | SL_Safari_7: { 63 | base: 'SauceLabs', 64 | browserName: 'safari', 65 | platform: 'OS X 10.9', 66 | version: '7.1', 67 | }, 68 | SL_Safari_8: { 69 | base: 'SauceLabs', 70 | browserName: 'safari', 71 | platform: 'OS X 10.10', 72 | version: '8', 73 | }, 74 | SL_Safari_9: { 75 | base: 'SauceLabs', 76 | browserName: 'safari', 77 | platform: 'OS X 10.11', 78 | version: '9', 79 | }, 80 | SL_IE_11: { 81 | base: 'SauceLabs', 82 | browserName: 'internet explorer', 83 | platform: 'Windows 10', 84 | version: '11', 85 | }, 86 | SL_Edge: { 87 | base: 'SauceLabs', 88 | browserName: 'edge', 89 | platform: 'Windows 10', 90 | version: '13', 91 | }, 92 | SL_iOS: { 93 | base: 'SauceLabs', 94 | browserName: 'iphone', 95 | platform: 'OS X 10.10', 96 | version: '8.1', 97 | }, 98 | /* eslint-enable camelcase */ 99 | }, 100 | }); 101 | 102 | if (process.env.TRAVIS) { 103 | const buildLabel = `TRAVIS #${process.env.TRAVIS_BUILD_NUMBER} (${process.env.TRAVIS_BUILD_ID})`; 104 | 105 | config.logLevel = config.LOG_DEBUG; 106 | 107 | config.reporters.push('saucelabs'); 108 | config.browsers = [ 109 | 'SL_Chrome', 110 | 'SL_Firefox', 111 | 'SL_IE_11', 112 | // 'SL_Safari_7', // SauceLabs errors, unavailable? 113 | 'SL_Safari_8', 114 | 'SL_Safari_9', 115 | // 'SL_Edge', // Edge seems to be having issues on saucelabs right now 116 | 'SL_iOS', 117 | ]; 118 | 119 | // Karma (with socket.io 1.x) buffers by 50 and 50 tests can take a long time on IEs;-) 120 | config.browserNoActivityTimeout = 120000; 121 | 122 | // config.browserStack.build = buildLabel; 123 | // config.browserStack.startTunnel = false; 124 | // config.browserStack.tunnelIdentifier = process.env.TRAVIS_JOB_NUMBER; 125 | 126 | config.sauceLabs.build = buildLabel; 127 | config.sauceLabs.startConnect = false; 128 | config.sauceLabs.tunnelIdentifier = process.env.TRAVIS_JOB_NUMBER; 129 | config.sauceLabs.recordScreenshots = true; 130 | 131 | // Allocating a browser can take pretty long (eg. if we are out of capacity and need to wait 132 | // for another build to finish) and so the `captureTimeout` typically kills 133 | // an in-queue-pending request, which makes no sense. 134 | config.captureTimeout = 0; 135 | } 136 | }; 137 | -------------------------------------------------------------------------------- /src/ecs.js: -------------------------------------------------------------------------------- 1 | import Entity from './entity'; 2 | import System from './system'; 3 | import performance from './performance'; 4 | import uid from './uid'; 5 | import { fastSplice } from './utils'; 6 | 7 | /** 8 | * 9 | * @class 10 | */ 11 | export default class ECS { 12 | /** 13 | * 14 | */ 15 | constructor() { 16 | /** 17 | * Store all entities of the ECS. 18 | * 19 | * @member {Entity[]} 20 | */ 21 | this.entities = []; 22 | 23 | /** 24 | * Store all systems of the ECS. 25 | * 26 | * @member {System[]} 27 | */ 28 | this.systems = []; 29 | 30 | /** 31 | * Count how many updates have been done. 32 | * 33 | * @member {number} 34 | */ 35 | this.updateCounter = 0; 36 | 37 | /** 38 | * The last timestamp of an update call. 39 | * 40 | * @member 41 | */ 42 | this.lastUpdate = performance.now(); 43 | } 44 | 45 | /** 46 | * Retrieve an entity by id. 47 | * 48 | * @param {number} id - id of the entity to retrieve 49 | * @return {Entity} The entity if found null otherwise 50 | */ 51 | getEntityById(id) { 52 | for (let i = 0; i < this.entities.length; ++i) { 53 | const entity = this.entities[i]; 54 | 55 | if (entity.id === id) { 56 | return entity; 57 | } 58 | } 59 | 60 | return null; 61 | } 62 | 63 | /** 64 | * Add an entity to the ecs. 65 | * 66 | * @param {Entity} entity - The entity to add. 67 | */ 68 | addEntity(entity) { 69 | this.entities.push(entity); 70 | 71 | entity.ecs = this; 72 | 73 | // iterate over all systems to setup valid systems 74 | for (let i = 0; i < this.systems.length; ++i) { 75 | const system = this.systems[i]; 76 | 77 | if (system.test(entity)) { 78 | system.addEntity(entity); 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * Remove an entity from the ecs by reference. 85 | * 86 | * @param {Entity} entity - reference of the entity to remove 87 | * @return {Entity} the remove entity if any 88 | */ 89 | removeEntity(entity) { 90 | const index = this.entities.indexOf(entity); 91 | 92 | // if the entity is not found do nothing 93 | if (index !== -1) { 94 | entity.dispose(); 95 | 96 | fastSplice(this.entities, index, 1); 97 | } 98 | 99 | return entity; 100 | } 101 | 102 | /** 103 | * Add a system to the ecs. 104 | * 105 | * @param {System} system - system to add 106 | */ 107 | addSystem(system) { 108 | this.systems.push(system); 109 | system.initialize(); 110 | 111 | // iterate over all entities to eventually add system 112 | for (let i = 0; i < this.entities.length; ++i) { 113 | const entity = this.entities[i]; 114 | 115 | if (system.test(entity)) { 116 | system.addEntity(entity); 117 | } 118 | } 119 | } 120 | 121 | /** 122 | * Remove a system from the ecs. 123 | * 124 | * @param {System} system system reference 125 | */ 126 | removeSystem(system) { 127 | const index = this.systems.indexOf(system); 128 | 129 | if (index !== -1) { 130 | fastSplice(this.systems, index, 1); 131 | system.dispose(); 132 | } 133 | } 134 | 135 | /** 136 | * Update the ecs. 137 | * 138 | * @method update 139 | */ 140 | update() { 141 | const now = performance.now(); 142 | const elapsed = now - this.lastUpdate; 143 | 144 | // update each entity 145 | for (let i = 0; i < this.entities.length; ++i) { 146 | const entity = this.entities[i]; 147 | 148 | for (let j = 0; j < entity.systems.length; ++j) { 149 | const system = entity.systems[j]; 150 | 151 | if (this.updateCounter % system.frequency > 0 || !system.enable) { 152 | continue; 153 | } 154 | 155 | system.update(entity, elapsed); 156 | } 157 | } 158 | 159 | this.updateCounter += 1; 160 | this.lastUpdate = now; 161 | } 162 | } 163 | 164 | // expose! 165 | ECS.Entity = Entity; 166 | ECS.System = System; 167 | ECS.uid = uid; 168 | 169 | /** 170 | * An interface describing components. 171 | * 172 | * @interface IComponent 173 | */ 174 | 175 | /** 176 | * The name of the component 177 | * 178 | * @property 179 | * @name IComponent#name 180 | * @type {string} 181 | */ 182 | 183 | /** 184 | * The factory function for the data of this component. 185 | * 186 | * @function 187 | * @name IComponent#data 188 | * @returns {*} The data object for this component. 189 | */ 190 | -------------------------------------------------------------------------------- /test/unit/ecs.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function returnTrue() { 4 | return true; 5 | } 6 | 7 | describe('ECS', function () { 8 | it('should initialize', function () { 9 | var ecs = new ECS(); 10 | 11 | expect(ecs.entities).to.be.an('array'); 12 | expect(ecs.systems).to.be.an('array'); 13 | }); 14 | 15 | describe('getEntityById()', function () { 16 | it('should retrieve an entity by id', function () { 17 | var ecs = new ECS(); 18 | var entity = new ECS.Entity(123); 19 | 20 | ecs.addEntity(entity); 21 | 22 | expect(ecs.getEntityById(123)).to.be.equal(entity); 23 | }); 24 | }); 25 | 26 | describe('update()', function () { 27 | var ecs = null; 28 | var entity = null; 29 | var system = null; 30 | 31 | beforeEach(function () { 32 | ecs = new ECS(); 33 | entity = new ECS.Entity(); 34 | system = new ECS.System(); 35 | }); 36 | 37 | it('should give the elapsed time to update methods', function (done) { 38 | system.test = returnTrue; 39 | system.update = function (entity, elapsed) { 40 | expect(elapsed).to.be.a('number'); 41 | done(); 42 | }; 43 | 44 | ecs.addSystem(system); 45 | ecs.addEntity(entity); 46 | 47 | ecs.update(); 48 | }); 49 | }); 50 | 51 | describe('addSystem()', function () { 52 | var ecs = null; 53 | var entity = null; 54 | var system = null; 55 | 56 | beforeEach(function () { 57 | ecs = new ECS(); 58 | entity = new ECS.Entity(); 59 | system = new ECS.System(); 60 | }); 61 | 62 | it('should call enter() when update', function () { 63 | system.test = returnTrue; 64 | system.enter = sinon.spy(); 65 | ecs.addSystem(system); 66 | ecs.addEntity(entity); 67 | 68 | ecs.update(); 69 | 70 | expect(system.enter.calledWith(entity)).to.be.equal(true); 71 | }); 72 | 73 | it('should call enter() when removing and re-adding a system', function () { 74 | system.test = returnTrue; 75 | system.enter = sinon.spy(); 76 | ecs.addSystem(system); 77 | ecs.addEntity(entity); 78 | ecs.update(); 79 | 80 | ecs.removeSystem(system); 81 | ecs.update(); 82 | 83 | ecs.addSystem(system); 84 | ecs.update(); 85 | 86 | expect(system.enter.calledTwice).to.be.equal(true); 87 | }); 88 | }); 89 | 90 | describe('removeSystem()', function () { 91 | var ecs = null; 92 | var entity = null; 93 | var system = null; 94 | 95 | beforeEach(function () { 96 | ecs = new ECS(); 97 | entity = new ECS.Entity(); 98 | system = new ECS.System(); 99 | }); 100 | 101 | it('should call exit(entity) when removed', function () { 102 | system.test = returnTrue; 103 | system.exit = sinon.spy(); 104 | 105 | ecs.addSystem(system); 106 | ecs.addEntity(entity); 107 | 108 | ecs.update(); 109 | 110 | ecs.removeSystem(system); 111 | 112 | expect(system.exit.calledWith(entity)).to.be.equal(true); 113 | }); 114 | 115 | it('should call exit(entity) of all systems when removed', function () { 116 | system.test = returnTrue; 117 | system.exit = sinon.spy(); 118 | 119 | ecs.addSystem(system); 120 | ecs.addEntity(entity); 121 | 122 | ecs.update(); 123 | 124 | ecs.removeSystem(system); 125 | 126 | expect(system.exit.calledWith(entity)).to.be.equal(true); 127 | }); 128 | }); 129 | 130 | describe('removeEntity()', function () { 131 | var ecs = null; 132 | var entity = null; 133 | var system1 = null; 134 | var system2 = null; 135 | 136 | beforeEach(function () { 137 | ecs = new ECS(); 138 | entity = new ECS.Entity(); 139 | system1 = new ECS.System(); 140 | system2 = new ECS.System(); 141 | }); 142 | 143 | it('should call exit(entity) when removed', function () { 144 | system1.test = returnTrue; 145 | system1.exit = sinon.spy(); 146 | 147 | ecs.addSystem(system1); 148 | ecs.addEntity(entity); 149 | 150 | ecs.update(); 151 | 152 | ecs.removeEntity(entity); 153 | 154 | expect(system1.exit.calledWith(entity)).to.be.equal(true); 155 | }); 156 | 157 | it('should call exit(entity) of all systems when removed', function () { 158 | system2.test = returnTrue; 159 | system2.exit = sinon.spy(); 160 | system1.test = returnTrue; 161 | system1.exit = sinon.spy(); 162 | 163 | ecs.addSystem(system1); 164 | ecs.addSystem(system2); 165 | ecs.addEntity(entity); 166 | 167 | ecs.update(); 168 | 169 | ecs.removeEntity(entity); 170 | 171 | expect(system1.exit.calledWith(entity)).to.be.equal(true); 172 | expect(system2.exit.calledWith(entity)).to.be.equal(true); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/entity.js: -------------------------------------------------------------------------------- 1 | import Symbol from 'core-js/es6/symbol'; 2 | import uid from './uid'; 3 | import { fastSplice } from './utils'; 4 | 5 | const _cachedApplicationRef = Symbol('_cachedApplicationRef'); 6 | const _componentList = Symbol('_componentList'); 7 | const _mixinRef = Symbol('_mixinRef'); 8 | 9 | /** 10 | * An entity. 11 | * 12 | * @class 13 | * @alias ECS.Entity 14 | */ 15 | export default class Entity { 16 | /** 17 | * 18 | * @param {number|UIDGenerator} idOrGenerator - The entity id if 19 | * a Number is passed. If an UIDGenerator is passed, the entity will use 20 | * it to generate a new id. If nothing is passed, the entity will use 21 | * the default UIDGenerator. 22 | */ 23 | constructor(idOrGenerator = uid.DefaultUIDGenerator) { 24 | /** 25 | * Unique identifier of the entity. 26 | * 27 | * @member {number} 28 | */ 29 | this.id = typeof idOrGenerator === 'number' ? idOrGenerator : idOrGenerator.next(); 30 | 31 | /** 32 | * Systems applied to the entity. 33 | * 34 | * @number {System[]} 35 | */ 36 | this.systems = []; 37 | 38 | /** 39 | * A reference to parent ECS class. 40 | * 41 | * @member {ECS} 42 | */ 43 | this.ecs = null; 44 | } 45 | 46 | /** 47 | * Checks if an entity has all the components passed. 48 | * 49 | * @example 50 | * 51 | * ```js 52 | * entity.hasComponents(Component1, Component2, ...); 53 | * ``` 54 | * 55 | * @alias hasComponent 56 | * @param {...Component} components - The component classes to compose into a parent class. 57 | * @return {Component} A base-class component to extend from. 58 | */ 59 | hasComponents(...components) { 60 | // Check that each passed component exists in the component list. 61 | // If it doesn't, then immediately return false. 62 | for (let i = 0; i < components.length; ++i) { 63 | const comp = components[i]; 64 | let o = Object.getPrototypeOf(this); 65 | let found = false; 66 | 67 | while (o) { 68 | if (Object.prototype.hasOwnProperty.call(o, _mixinRef) && o[_mixinRef] === comp) { 69 | found = true; 70 | break; 71 | } 72 | o = Object.getPrototypeOf(o); 73 | } 74 | 75 | // if we traveled the chain and never found the component we 76 | // were looking for, then its done. 77 | if (!found) { 78 | return false; 79 | } 80 | } 81 | 82 | return true; 83 | } 84 | 85 | /** 86 | * Dispose the entity. 87 | * 88 | * @private 89 | */ 90 | dispose() { 91 | while (this.systems.length) { 92 | this.systems[this.systems.length - 1].removeEntity(this); 93 | } 94 | } 95 | 96 | /** 97 | * Add a system to the entity. 98 | * 99 | * @private 100 | * @param {System} system - The system to add. 101 | */ 102 | _addSystem(system) { 103 | this.systems.push(system); 104 | } 105 | 106 | /** 107 | * Remove a system from the entity. 108 | * 109 | * @private 110 | * @param {System} system - The system reference to remove. 111 | */ 112 | _removeSystem(system) { 113 | const index = this.systems.indexOf(system); 114 | 115 | if (index !== -1) { 116 | fastSplice(this.systems, index, 1); 117 | } 118 | } 119 | } 120 | 121 | Entity.prototype.hasComponent = Entity.prototype.hasComponents; 122 | 123 | /** 124 | * Composes an entity with the given components. 125 | * 126 | * @example 127 | * 128 | * ```js 129 | * class MyEntity extends ECS.Entity.with(Component1, Component2, ...) { 130 | * } 131 | * ``` 132 | * 133 | * @static 134 | * @param {...Component} components - The component classes to compose into a parent class. 135 | * @return {Component} A base-class component to extend from. 136 | */ 137 | Entity.with = function entityWith(...components) { 138 | const Clazz = components.reduce((base, comp) => { 139 | // Get or create a symbol used to look up a previous application of mixin 140 | // to the class. This symbol is unique per mixin definition, so a class will have N 141 | // applicationRefs if it has had N mixins applied to it. A mixin will have 142 | // exactly one _cachedApplicationRef used to store its applications. 143 | let ref = comp[_cachedApplicationRef]; 144 | 145 | if (!ref) { 146 | ref = comp[_cachedApplicationRef] = Symbol(comp.name); 147 | } 148 | 149 | // look up cached version of mixin/superclass 150 | if (Object.prototype.hasOwnProperty.call(base, ref)) { 151 | return base[ref]; 152 | } 153 | 154 | // apply the component 155 | const app = comp(base); 156 | 157 | // cache it so we don't make it again 158 | base[ref] = app; 159 | 160 | // store the mixin we applied here. 161 | app.prototype[_mixinRef] = comp; 162 | 163 | return app; 164 | }, this); 165 | 166 | Clazz.prototype[_componentList] = components; 167 | 168 | return Clazz; 169 | }; 170 | 171 | // export some symbols 172 | Entity._cachedApplicationRef = _cachedApplicationRef; 173 | Entity._componentList = _componentList; 174 | --------------------------------------------------------------------------------