├── imgs ├── ape_ecs900.png └── ape_ecs900wbg.png ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .travis.yml ├── tsconfig.json ├── src ├── index.mjs ├── index.js ├── cleanup.js ├── util.js ├── componentpool.js ├── system.js ├── entitypool.js ├── entityrefs.js ├── component.js ├── entity.js ├── index.d.ts ├── query.js └── world.js ├── .nycrc ├── webpack.config.js ├── docs ├── API_Reference.md ├── Entity_Refs.md ├── Patterns.md ├── Entity.md ├── System.md ├── Overview.md ├── Query.md ├── Component.md └── World.md ├── LICENSE ├── CHANGELOG.md ├── package.json ├── benchmark.js ├── webbenchmark └── index.js ├── README.md └── tests └── index.ts /imgs/ape_ecs900.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fritzy/ape-ecs/HEAD/imgs/ape_ecs900.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | coverage 4 | isolate-* 5 | webbenchmark/build 6 | -------------------------------------------------------------------------------- /imgs/ape_ecs900wbg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fritzy/ape-ecs/HEAD/imgs/ape_ecs900wbg.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests 2 | webbenchmark 3 | webpack.config.js 4 | docs 5 | imgs 6 | builds 7 | .prettierrc.json 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14 4 | - 13 5 | - 12 6 | after_success: "npm run coverage" 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "sourceMap": true, 5 | "target": "es2015" 6 | } 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/index.mjs: -------------------------------------------------------------------------------- 1 | import * as EntityRef from './entityrefs.js'; 2 | export { EntityRef }; 3 | export * as World from './world.js'; 4 | export * as System from './system.js'; 5 | export * as Component from './component.js'; 6 | export * as Entity from './entity.js'; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { EntityRef, EntitySet, EntityObject } = require('./entityrefs'); 2 | module.exports = { 3 | World: require('./world'), 4 | System: require('./system'), 5 | Component: require('./component'), 6 | Entity: require('./entity'), 7 | EntityRef, 8 | EntitySet, 9 | EntityObject 10 | }; 11 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "cache": false, 3 | "check-coverage": false, 4 | "extension": [ 5 | ".ts" 6 | ], 7 | "include": [ 8 | "src/*.js" 9 | ], 10 | "exclude": [ 11 | "coverage/**", 12 | "node_modules/**", 13 | "**/*.d.ts", 14 | "**/*.test.ts" 15 | ], 16 | "sourceMap": true, 17 | "reporter": [ 18 | "html", 19 | "text", 20 | "text-summary" 21 | ], 22 | "all": true, 23 | "instrument": true 24 | } 25 | -------------------------------------------------------------------------------- /src/cleanup.js: -------------------------------------------------------------------------------- 1 | const System = require('./system'); 2 | 3 | class CleanupApeDestroySystem extends System { 4 | init() { 5 | this.destroyQuery = this.createQuery({ includeApeDestroy: true }) 6 | .fromAll('ApeDestroy') 7 | .persist(); 8 | } 9 | 10 | update() { 11 | const entities = this.destroyQuery.execute(); 12 | for (const entity of entities) { 13 | entity.destroy(); 14 | } 15 | } 16 | } 17 | 18 | function setupApeDestroy(world) { 19 | world.registerTags('ApeDestroy'); 20 | world.registerSystem('ApeCleanup', CleanupApeDestroySystem); 21 | } 22 | 23 | module.exports = setupApeDestroy; 24 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const TerserPlugin = require("terser-webpack-plugin"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | 5 | module.exports = { 6 | entry: "./webbenchmark/index.js", 7 | devtool: "source-map", 8 | mode: "production", 9 | optimization: { 10 | minimizer: [ 11 | new TerserPlugin({ 12 | terserOptions: { 13 | mangle: false, 14 | }, 15 | }), 16 | ], 17 | }, 18 | output: { 19 | path: path.resolve(__dirname, "webbenchmark/build"), 20 | filename: "out.js", 21 | }, 22 | devServer: { 23 | contentBase: path.join(__dirname, "webbenchmark"), 24 | }, 25 | plugins: [ 26 | new HtmlWebpackPlugin({ 27 | title: "APE-ECS Web Benchmark", 28 | }), 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /docs/API_Reference.md: -------------------------------------------------------------------------------- 1 | # Ape ECS API Reference 2 | * [World](./World.md) - How to use an **Ape ECS** World to manage everything. 3 | * [Entity](./Entity.md) - How to work with Entities. 4 | * [Component](./Component.md) - How to work with Components. 5 | * [System](./System.md) - How to work with Systems. 6 | * [Query](./Query.md) - How to work with Queries. 7 | * [Entity Refences](./Entity_Refs.md) - How to work with Entity References. 8 | 9 | ## Other Documentation 10 | * [Overview](./Overview.md) - Overview of how the peices fit together in **Ape ECS** 11 | * [Patterns](./Patterns.md) - Common solutions to game/simulation problems with **ApeECS**. 12 | * [Back to the README](../README.md) 13 | 14 | These docs contain emojis to indicate the type of information following. 15 | 16 | 👆 A relevant tip. 17 | ⚠️ A warning and possible 🦶🔫. 18 | 💭 Implementation details. 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2020 Nathanael C. Fritz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 November 1, 2020 2 | 3 | Performance improvements through id generation. Thanks Martin Emmert! 4 | Entity/Component creation benchmarks have improved by 40%+! 5 | Build sizes are also significantly smaller due to no longer using the node.js `crypto` library. 6 | 7 | * Swap to UUIDv4 for performance improvements \([martinemmert](https://github.com/martinemmert)\] 8 | * Fix of webbenchmark \[[martinemmert](https://github.com/martinemmert)\] 9 | * Web Benchmark is now apples-to-apples comparison, reflected in Overview.md \[[fritzy](https://github.com/fritzy)\] 10 | 11 | ## 1.2.0 November 22, 2020 12 | 13 | * Components can be registered to multiple worlds. (more multi-world support to come) 14 | * Prettier config and formatting. 15 | * Components typeName static property to deal with minimizers (compiling packages sometimes change function/class names). 16 | * Custom System init parameters. 17 | * TypeScript fixes. 18 | * System.subscribe will now take Component class as well as name (to match other functions that take Component type). 19 | 20 | ## 1.3.0 November 29, 2020 21 | 22 | * Fix id prefixes 23 | * added typeName Component property 24 | 25 | ## 1.3.1 February 1, 2021 26 | 27 | * check for calling entity.destroy() twice 28 | * updated Docs to not use static property pattern 29 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | class IdGenerator { 2 | constructor() { 3 | this.gen_num = 0; 4 | this.prefix = ''; 5 | this.genPrefix(); 6 | } 7 | 8 | genPrefix() { 9 | this.prefix = Date.now().toString(32); 10 | } 11 | 12 | genId() { 13 | this.gen_num++; 14 | // istanbul ignore if 15 | if (this.gen_num === 4294967295) { 16 | this.gen_num = 0; 17 | this.genPrefix(); 18 | } 19 | return this.prefix + this.gen_num; 20 | } 21 | } 22 | 23 | function setIntersection() { 24 | let sets = Array.from(arguments), 25 | setSizes = sets.map((set) => set.size), 26 | smallestSetIndex = setSizes.indexOf(Math.min.apply(Math, setSizes)), 27 | smallestSet = sets[smallestSetIndex], 28 | result = new Set(smallestSet); 29 | 30 | sets.splice(smallestSetIndex, 1); 31 | 32 | smallestSet.forEach((value) => { 33 | for (let i = 0; i < sets.length; i += 1) { 34 | if (!sets[i].has(value)) { 35 | result.delete(value); 36 | break; 37 | } 38 | } 39 | }); 40 | 41 | return result; 42 | } 43 | 44 | function setUnion() { 45 | let result = new Set(); 46 | 47 | Array.from(arguments).forEach((set) => { 48 | set.forEach((value) => result.add(value)); 49 | }); 50 | 51 | return result; 52 | } 53 | 54 | module.exports = { 55 | IdGenerator, 56 | setIntersection, 57 | setUnion 58 | }; 59 | -------------------------------------------------------------------------------- /src/componentpool.js: -------------------------------------------------------------------------------- 1 | class ComponentPool { 2 | constructor(world, type, spinup) { 3 | this.world = world; 4 | this.type = type; 5 | this.klass = this.world.componentTypes[this.type]; 6 | this.pool = []; 7 | this.targetSize = spinup; 8 | this.active = 0; 9 | this.spinUp(spinup); 10 | } 11 | 12 | get(entity, initial) { 13 | let comp; 14 | if (this.pool.length === 0) { 15 | comp = new this.klass(this.world); 16 | } else { 17 | comp = this.pool.pop(); 18 | } 19 | comp._setup(entity, initial); 20 | this.active++; 21 | return comp; 22 | } 23 | 24 | release(comp) { 25 | comp._reset(); 26 | //comp._meta.entity = null; 27 | this.pool.push(comp); 28 | this.active--; 29 | } 30 | 31 | cleanup() { 32 | if (this.pool.length > this.targetSize * 2) { 33 | const diff = this.pool.length - this.targetSize; 34 | const chunk = Math.max(Math.min(20, diff), Math.floor(diff / 4)); 35 | for (let i = 0; i < chunk; i++) { 36 | this.pool.pop(); 37 | } 38 | } 39 | } 40 | 41 | spinUp(count) { 42 | for (let i = 0; i < count; i++) { 43 | const comp = new this.klass(this.world); 44 | this.pool.push(comp); 45 | } 46 | this.targetSize = Math.max(this.targetSize, this.pool.length); 47 | } 48 | } 49 | 50 | module.exports = ComponentPool; 51 | -------------------------------------------------------------------------------- /src/system.js: -------------------------------------------------------------------------------- 1 | const Query = require('./query'); 2 | 3 | class System { 4 | constructor(world, ...initArgs) { 5 | this.world = world; 6 | this._stagedChanges = []; 7 | this.changes = []; 8 | this.queries = []; 9 | this.lastTick = this.world.currentTick; 10 | if (this.constructor.subscriptions) { 11 | for (const sub of this.constructor.subscriptions) { 12 | this.subscribe(sub); 13 | } 14 | } 15 | this.init(...initArgs); 16 | } 17 | 18 | init() {} 19 | 20 | update(tick) {} 21 | 22 | createQuery(init) { 23 | return new Query(this.world, this, init); 24 | } 25 | 26 | subscribe(type) { 27 | if (typeof type !== 'string') { 28 | type = type.name; 29 | } 30 | if (!this.world.subscriptions.has(type)) { 31 | this.world.componentTypes[type].subbed = true; 32 | this.world.subscriptions.set(type, new Set()); 33 | } 34 | this.world.subscriptions.get(type).add(this); 35 | } 36 | 37 | _preUpdate() { 38 | this.changes = this._stagedChanges; 39 | this._stagedChanges = []; 40 | this.world.updateIndexes(); 41 | } 42 | 43 | _postUpdate() { 44 | for (const query of this.queries) { 45 | query.clearChanges(); 46 | } 47 | } 48 | 49 | _recvChange(change) { 50 | this._stagedChanges.push(change); 51 | } 52 | } 53 | 54 | module.exports = System; 55 | -------------------------------------------------------------------------------- /src/entitypool.js: -------------------------------------------------------------------------------- 1 | const Entity = require('./entity'); 2 | 3 | class EntityPool { 4 | constructor(world, spinup) { 5 | this.world = world; 6 | this.pool = []; 7 | this.destroyed = []; 8 | this.worldEntity = class WorldEntity extends Entity {}; 9 | this.worldEntity.prototype.world = this.world; 10 | this.spinUp(spinup); 11 | this.targetSize = spinup; 12 | } 13 | 14 | destroy(entity) { 15 | this.destroyed.push(entity); 16 | } 17 | 18 | get(definition, onlyComponents = false) { 19 | let entity; 20 | if (this.pool.length === 0) { 21 | entity = new this.worldEntity(); 22 | } else { 23 | entity = this.pool.pop(); 24 | } 25 | entity._setup(definition, onlyComponents); 26 | return entity; 27 | } 28 | 29 | release() { 30 | while (this.destroyed.length > 0) { 31 | const entity = this.destroyed.pop(); 32 | this.pool.push(entity); 33 | } 34 | } 35 | 36 | cleanup() { 37 | if (this.pool.length > this.targetSize * 2) { 38 | const diff = this.pool.length - this.targetSize; 39 | const chunk = Math.max(Math.min(20, diff), Math.floor(diff / 4)); 40 | for (let i = 0; i < chunk; i++) { 41 | this.pool.pop(); 42 | } 43 | } 44 | } 45 | 46 | spinUp(count) { 47 | for (let i = 0; i < count; i++) { 48 | const entity = new this.worldEntity(); 49 | this.pool.push(entity); 50 | } 51 | this.targetSize = Math.max(this.targetSize, this.pool.length); 52 | } 53 | } 54 | 55 | module.exports = EntityPool; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ape-ecs", 3 | "version": "1.3.1", 4 | "description": "Ape-ECS (Apex) Entity-Component-System library for simulation and game development.", 5 | "main": "./src/index.js", 6 | "types": "./src/index.d.ts", 7 | "source": true, 8 | "files": [ 9 | "/src" 10 | ], 11 | "scripts": { 12 | "test": "nyc npm run test_only", 13 | "test_only": "cross-env TS_NODE_FILES=true mocha --exit --require ts-node/register --colors tests/*.ts", 14 | "testp": "mocha tests/index.js", 15 | "coverage": "nyc report --reporter=text-lcov | coveralls", 16 | "build": "webpack --config ./build.webpack.config.js", 17 | "md": "jsdoc2md src/ecs.js", 18 | "prettier": "prettier --write src/", 19 | "webbench": "webpack-dev-server", 20 | "check-links": "markdown-link-check README.md && markdown-link-check docs/API_Reference.md && markdown-link-check docs/Component.md && markdown-link-check docs/Entity.md && markdown-link-check docs/Entity_Refs.md && markdown-link-check docs/Overview.md && markdown-link-check docs/Patterns.md && markdown-link-check docs/Query.md && markdown-link-check docs/System.md && markdown-link-check docs/World.md" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/fritzy/ape-ecs.git" 25 | }, 26 | "author": "Nathanael Fritz", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/fritzy/ape-ecs/issues" 30 | }, 31 | "homepage": "https://github.com/fritzy/ape-ecs#readme", 32 | "dependencies": {}, 33 | "devDependencies": { 34 | "@hapi/eslint-plugin-hapi": "^4.3.5", 35 | "@types/chai": "^4.2.12", 36 | "@types/mocha": "^8.0.3", 37 | "@types/node": "^14.6.1", 38 | "chai": "^4.2.0", 39 | "coveralls": "^3.1.0", 40 | "cross-env": "^7.0.2", 41 | "html-webpack-plugin": "^4.5.0", 42 | "markdown-link-check": "^3.8.3", 43 | "mocha": "^8.1.2", 44 | "nyc": "^15.1.0", 45 | "prettier": "^2.2.0", 46 | "ts-node": "^9.0.0", 47 | "typescript": "^4.0.2", 48 | "webpack": "^4.43.0", 49 | "webpack-cli": "^3.3.11", 50 | "webpack-dev-server": "^3.11.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | const CREATE = 50000; 2 | const ECS = require('./src/index'); 3 | const perf_hooks = require('perf_hooks'); 4 | 5 | const descriptions = { 6 | create2Comp: 'Create 50,000 entities with two simple components ', 7 | destroy2Comp: 'Destroy 50,000 entities with two simple components', 8 | recreating: 'Recreating components now that pool is established', 9 | rewriteComp: 'Changing the values of each component ' 10 | } 11 | 12 | const times = { 13 | create2Comp: 0, 14 | destroy2Comp: 0, 15 | recreating: 0 16 | }; 17 | 18 | function output(test) { 19 | 20 | console.log(`${descriptions[test]}: ${(times[test]).toFixed(2)}ms`); 21 | } 22 | 23 | class Test extends ECS.Component { 24 | static properties = { 25 | a: 1, 26 | b: 2 27 | }; 28 | } 29 | class Test2 extends ECS.Component { 30 | static properties = { 31 | c: 3, 32 | d: 4 33 | }; 34 | } 35 | 36 | function benchmarks() { 37 | let start, end; 38 | 39 | 40 | const ecs = new ECS.World({ trackChanges: false, entityPool: 100 }); 41 | ecs.registerComponent(Test); 42 | ecs.registerComponent(Test2); 43 | 44 | const entities = []; 45 | 46 | console.log(`Creating and destroying ${CREATE} entities...`); 47 | 48 | start = perf_hooks.performance.now(); 49 | 50 | for (let i = 0; i < CREATE; i++) { 51 | 52 | entities.push( 53 | ecs.createEntity({ 54 | components: [ 55 | { 56 | type: 'Test', 57 | key: 'Test', 58 | a: 4, 59 | b: 5 60 | }, 61 | { 62 | type: 'Test2', 63 | key: 'Test2', 64 | c: 6, 65 | d: 7 66 | } 67 | ] 68 | }) 69 | ); 70 | } 71 | end = perf_hooks.performance.now(); 72 | times.create2Comp = end - start; 73 | output('create2Comp'); 74 | 75 | start = perf_hooks.performance.now(); 76 | for (let i = 0; i < CREATE; i++) { 77 | entities[i].c.Test.a = 14; 78 | entities[i].c.Test.b = 15; 79 | entities[i].c.Test2.c = 16; 80 | entities[i].c.Test2.d = 17; 81 | } 82 | end = perf_hooks.performance.now(); 83 | times.rewriteComp = end - start; 84 | output('rewriteComp'); 85 | 86 | start = perf_hooks.performance.now(); 87 | for (let i = 0; i < CREATE; i++) { 88 | entities[i].destroy(); 89 | } 90 | end = perf_hooks.performance.now(); 91 | times.destroy2Comp = end - start; 92 | output('destroy2Comp'); 93 | 94 | 95 | start = perf_hooks.performance.now(); 96 | for (let i = 0; i < CREATE; i++) { 97 | entities.push( 98 | ecs.createEntity({ 99 | components: [ 100 | { 101 | type: 'Test', 102 | lookup: 'Test', 103 | a: 4, 104 | b: 5 105 | }, 106 | { 107 | type: 'Test2', 108 | lookup: 'Test2', 109 | c: 6, 110 | d: 7 111 | } 112 | ] 113 | }) 114 | ); 115 | } 116 | end = perf_hooks.performance.now(); 117 | times.recreating = end - start; 118 | output('recreating'); 119 | 120 | } 121 | 122 | benchmarks(); 123 | 124 | 125 | -------------------------------------------------------------------------------- /webbenchmark/index.js: -------------------------------------------------------------------------------- 1 | const CREATE = 50000; 2 | const ECS = require('../src/index.js'); 3 | 4 | const descriptions = { 5 | create2Comp: 'Create 50,000 entities with two simple components ', 6 | destroy2Comp: 'Destroy 50,000 entities with two simple components', 7 | recreating: 'Recreating components now that pool is established', 8 | rewriteComp: 'Changing the values of each component ', 9 | }; 10 | 11 | const times = { 12 | create2Comp: 0, 13 | destroy2Comp: 0, 14 | recreating: 0, 15 | }; 16 | 17 | function output(test) { 18 | console.log(`${descriptions[test]}: ${times[test].toFixed(2)}ms`); 19 | } 20 | 21 | function benchmarks() { 22 | let start, end; 23 | 24 | class Test extends ECS.Component {} 25 | Test.properties = { 26 | a: 1, 27 | b: 2, 28 | }; 29 | 30 | class Test2 extends ECS.Component {} 31 | Test2.properties = { 32 | c: 3, 33 | d: 4, 34 | }; 35 | 36 | const ecs = new ECS.World({ trackChanges: false, entityPool: 100 }); 37 | ecs.registerComponent(Test); 38 | ecs.registerComponent(Test2); 39 | 40 | const entities = []; 41 | 42 | console.log(`Creating and destroying ${CREATE} entities...`); 43 | 44 | start = performance.now(); 45 | 46 | for (let i = 0; i < CREATE; i++) { 47 | entities.push( 48 | ecs.createEntity({ 49 | components: [ 50 | { 51 | type: 'Test', 52 | key: 'Test', 53 | a: 4, 54 | b: 5, 55 | }, 56 | { 57 | type: 'Test2', 58 | key: 'Test2', 59 | c: 6, 60 | d: 7, 61 | }, 62 | ], 63 | }) 64 | ); 65 | } 66 | 67 | end = performance.now(); 68 | times.create2Comp = end - start; 69 | output('create2Comp'); 70 | 71 | start = performance.now(); 72 | for (let i = 0; i < CREATE; i++) { 73 | entities[i].c.Test.a = 14; 74 | entities[i].c.Test.b = 15; 75 | entities[i].c.Test2.c = 16; 76 | entities[i].c.Test2.d = 17; 77 | } 78 | end = performance.now(); 79 | times.rewriteComp = end - start; 80 | output('rewriteComp'); 81 | 82 | start = performance.now(); 83 | for (let i = 0; i < CREATE; i++) { 84 | entities[i].destroy(); 85 | } 86 | end = performance.now(); 87 | times.destroy2Comp = end - start; 88 | output('destroy2Comp'); 89 | 90 | start = performance.now(); 91 | for (let i = 0; i < CREATE; i++) { 92 | entities.push( 93 | ecs.createEntity({ 94 | components: [ 95 | { 96 | type: 'Test', 97 | lookup: 'Test', 98 | a: 4, 99 | b: 5, 100 | }, 101 | { 102 | type: 'Test2', 103 | lookup: 'Test2', 104 | c: 6, 105 | d: 7, 106 | }, 107 | ], 108 | }) 109 | ); 110 | } 111 | end = performance.now(); 112 | times.recreating = end - start; 113 | output('recreating'); 114 | showButton(); 115 | } 116 | 117 | function tick() { 118 | disableButton(); 119 | console.log('starting Benchmark'); 120 | setTimeout(benchmarks, 200); 121 | } 122 | 123 | function showButton() { 124 | document.body.innerHTML = ''; 125 | const button = document.createElement('button'); 126 | button.id = 'run-button'; 127 | button.innerHTML = 'run test'; 128 | button.addEventListener('click', tick); 129 | document.body.appendChild(button); 130 | } 131 | 132 | function disableButton() { 133 | document.body.innerHTML = 'benchmark is running'; 134 | } 135 | 136 | window.document.addEventListener('readystatechange', () => { 137 | if (window.document.readyState === 'complete') { 138 | showButton(); 139 | } 140 | }); 141 | -------------------------------------------------------------------------------- /src/entityrefs.js: -------------------------------------------------------------------------------- 1 | class EntitySet extends Set { 2 | constructor(component, object, field) { 3 | super(); 4 | this.component = component; 5 | this.field = field; 6 | this.sub = '__set__'; 7 | object = object.map((value) => 8 | typeof value === 'string' ? value : value.id 9 | ); 10 | this.dvalue = object; 11 | for (const item of object) { 12 | this.add(item); 13 | } 14 | } 15 | 16 | _reset() { 17 | this.clear(); 18 | for (const item of this.dvalue) { 19 | this.add(item); 20 | } 21 | } 22 | 23 | add(value) { 24 | if (value.id) { 25 | value = value.id; 26 | } 27 | this.component._addRef(value, this.field, '__set__'); 28 | super.add(value); 29 | } 30 | 31 | delete(value) { 32 | if (value.id) { 33 | value = value.id; 34 | } 35 | this.component._deleteRef(value, this.field, '__set__'); 36 | const res = super.delete(value); 37 | return res; 38 | } 39 | 40 | has(value) { 41 | if (value.id) { 42 | value = value.id; 43 | } 44 | return super.has(value); 45 | } 46 | 47 | [Symbol.iterator]() { 48 | const that = this; 49 | const siterator = super[Symbol.iterator](); 50 | return { 51 | next() { 52 | const result = siterator.next(); 53 | if (typeof result.value === 'string') { 54 | result.value = that.component.entity.world.getEntity(result.value); 55 | } 56 | return result; 57 | } 58 | }; 59 | } 60 | 61 | getValue() { 62 | return [...this].map((entity) => entity.id); 63 | } 64 | } 65 | 66 | module.exports = { 67 | EntityRef(comp, dvalue, field) { 68 | dvalue = dvalue || null; 69 | if (!comp.hasOwnProperty(field)) { 70 | Object.defineProperty(comp, field, { 71 | get() { 72 | return comp.world.getEntity(comp._meta.values[field]); 73 | }, 74 | set(value) { 75 | const old = comp._meta.values[field]; 76 | value = value && typeof value !== 'string' ? value.id : value; 77 | if (old && old !== value) { 78 | comp._deleteRef(old, field, undefined); 79 | } 80 | if (value && value !== old) { 81 | comp._addRef(value, field, undefined); 82 | } 83 | comp._meta.values[field] = value; 84 | } 85 | }); 86 | } 87 | comp[field] = dvalue; 88 | return; 89 | }, 90 | 91 | EntityObject(comp, object, field) { 92 | comp._meta.values[field] = object || {}; 93 | const values = comp._meta.values[field]; 94 | const keys = Object.keys(values); 95 | for (const key of keys) { 96 | if (values[key] && values[key].id) { 97 | values[key] = values[key].id; 98 | } 99 | } 100 | return new Proxy(comp._meta.values[field], { 101 | get(obj, prop) { 102 | return comp.world.getEntity(obj[prop]); 103 | }, 104 | set(obj, prop, value) { 105 | const old = obj[prop]; 106 | if (value && value.id) { 107 | value = value.id; 108 | } 109 | obj[prop] = value; 110 | if (old && old !== value) { 111 | comp._deleteRef(old, `${field}.${prop}`, '__obj__'); 112 | } 113 | if (value && value !== old) { 114 | comp._addRef(value, `${field}.${prop}`, '__obj__'); 115 | } 116 | return true; 117 | }, 118 | deleteProperty(obj, prop) { 119 | if (!obj.hasOwnProperty(prop)) return false; 120 | const old = obj[prop]; 121 | delete obj[prop]; 122 | comp._deleteRef(old, `${field}.${prop}`, '__obj__'); 123 | return true; 124 | } 125 | }); 126 | }, 127 | 128 | EntitySet(component, object = [], field) { 129 | return new EntitySet(component, object, field); 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /docs/Entity_Refs.md: -------------------------------------------------------------------------------- 1 | # Entity References 2 | 3 | **ApeECS** includes 3 types of Entity Reference -- a direct `EntityRef` and two Objects for references, `EntitySet` and `EntityObject`. 4 | EntityRefs accept either `Entity` instances or their ids, and always return the `Entity` instance. 5 | If the `Entity` instance is destroyed, then the references changes to `null`. 6 | Entity References also facilitaty the [query.fromReverse](./Query.md#fromreverse) for finding Entities/Components that reference a given `Entity` instance through a specific `Component` type. 7 | When you recreate Entities from serialized data, the Entity References are restored. 8 | 9 | Entity references themselves are functions that set up the property. 10 | Simply assign the function to a Component.properties value. 11 | 12 | ```js 13 | class InventorySlot extends ApeECS.Component {} 14 | InventorySlot.properties = { 15 | name: 'Right Hand', 16 | slotType: 'any', 17 | slot: ApeECS.EntityRef 18 | }; 19 | 20 | class Bottle extends ApeECS.Component {} 21 | Bottle.properties = { 22 | liquid: 'water', 23 | amount: 1 24 | } 25 | 26 | world.registerComponent(InventorySlot); 27 | world.registerComponent(Bottle); 28 | world.registerTags('Character'); 29 | 30 | const bottle = world.createEntity({ 31 | components: [ 32 | { 33 | type: 'Bottle', 34 | liquid: 'lava', 35 | amount: 0.75 36 | } 37 | ] 38 | }); 39 | 40 | const npc = world.createEntity({ 41 | tags: ['Character'], 42 | c: { 43 | rightHand: { 44 | type: 'InventorySlot', 45 | }, 46 | leftHand: { 47 | type: 'InventorySlot', 48 | } 49 | } 50 | }); 51 | 52 | npc.c.rightHand.slot = bottle; 53 | console.log(npc.rightHand.slot === bottle); // true 54 | 55 | const entities = world.creatQuery().fromAll('Character').fromReverse(bottle, 'InventorySlot').execute(); 56 | const entity = [...entities][0]; 57 | assert(entity === npc); 58 | bottle.destroy(); 59 | console.log(npc.rightHand.slot === null); // true 60 | ``` 61 | 62 | ## EntityRef 63 | 64 | `Component` property factory function for defining a single Entity reference. 65 | Assign `Entity` instances, `Entity` ids, or `null` to this property. 66 | Serializes to the `Entity` id. 67 | 68 | ## EntityObject 69 | 70 | `Component` property factory that creates a `Proxy` Object on that property where every key is effectively an `EntityRef`. 71 | Serializes to an `Object` of `Entity` ids and restores back to an `EntityObject`. 72 | 73 | ```js 74 | class CarParts extends ApeECS.Component {} 75 | CarParts.properties = { 76 | mountPoints: ApeECS.EntityObject 77 | }; 78 | 79 | world.registerComponents(CarParts) 80 | 81 | const car = world.creatEntity({ 82 | c: { 83 | carParts: { 84 | type: 'CarParts' 85 | } 86 | } 87 | }); 88 | 89 | // assume we have an entity coilovers; 90 | car.carParts.mountPoints.shocks = coilovers; 91 | // assume we have an entity sportTires; 92 | car.carParts.mountPoints.tires = sportTires; 93 | 94 | console.log(JSON.stringify(car.getObject)); 95 | ``` 96 | ```json 97 | { 98 | "id": "lkajdsf-132", 99 | "tags": [], 100 | "c": { 101 | "carParts": { 102 | "id": "lfjffff0-12", 103 | "type": "CarParts", 104 | "mountPoints": { 105 | "shocks": "ffdadfdas-32", 106 | "tires": "ujfjsjf-44" 107 | } 108 | } 109 | } 110 | } 111 | ``` 112 | 113 | ## EntitySet 114 | 115 | `Component` property factory that creates a `Set` that manages Entity references. 116 | 117 | ```js 118 | class Inventory extends ApeECS.Component {} 119 | Inventory.properties = { 120 | slots: ApeECS.EntitySet 121 | }; 122 | world.registerComponent(Inventory); 123 | 124 | const npc = world.createEntity({ 125 | c: { 126 | inventory: { 127 | type: 'Inventory' 128 | } 129 | } 130 | }); 131 | 132 | // assume we have a bottle entity 133 | npc.inventory.slots.add(bottle); 134 | console.log(npc.inventory.slots.has(bottle)); // true 135 | console.log(npc.inventory.slots.has(bottle.id)); // true 136 | bottle.destroy(); 137 | console.log(npc.inventory.slots.has(bottle)); // false 138 | ``` 139 | 140 | -------------------------------------------------------------------------------- /docs/Patterns.md: -------------------------------------------------------------------------------- 1 | # Patterns 2 | 3 | Some helpful patterns to using Ape ECS. 4 | Contributions encouraged! 5 | 6 | ## Input and Intentions 7 | 8 | One of the great things about the ECS pattern is that not everything needs to be in the ECS registry itself. 9 | Feel free to use external libraries and tools for things like mapping, graphics, and input, but it should interface with the ECS game state in some way. 10 | 11 | In the case of input, it's nice to attach an action component to an entity. 12 | Those actions could come from user input or NPC AI or network. 13 | 14 | ```js 15 | class Position extends ApeECS.Component {} 16 | Position.properties = { 17 | x: 0, 18 | y: 0 19 | }; 20 | 21 | // we could just make a separate action for each direction as tags 22 | // but this is more flexible 23 | class ActionMove extends ApeECS.Component {} 24 | ActionMove.properties = { 25 | x: 0, 26 | y: 0 27 | }; 28 | 29 | class ActionSystem extends ApeECS.System { 30 | 31 | init() { 32 | 33 | // here we're just dealing with movement, but an action could be any action 34 | // that a player or game agent intends to take 35 | 36 | this.moveQuery = this.createQuery() 37 | .fromAll('MoveAction', 'Position'); 38 | } 39 | 40 | update(tick) { 41 | 42 | const entities = this.moveQuery.execute(); 43 | for (const entity of entities) { 44 | // getOne because we only expect one Position on an entity 45 | const pos = entity.getOne('Position'); 46 | for (const move of entity.getComponents('MoveAction')) { 47 | 48 | // You would probably check to make sure they can move that direction 49 | // but I leave that as an exercise for the reader. 50 | // You might also want to attach animations for an animation system here. 51 | 52 | // You could just directly manipulation pos.x, pos.y but we won't get 53 | // proper information on mutations that way. 54 | // You could also directly update pos.x and pos.y and then call 55 | // pos.update() without arguments. 56 | pos.update({ 57 | x: pos.x + move.x, 58 | y: pos.y + move.y 59 | }); 60 | // remove the used action 61 | entity.removeComponent(move); 62 | } 63 | } 64 | } 65 | } 66 | 67 | class GameLoop { 68 | 69 | constructor() { 70 | 71 | this.world = new ApeECS.World(); 72 | //register your components 73 | this.world.registerComponent(Position, 10); 74 | this.world.registerComponent(ActionMove, 10); 75 | this.world.registerTags('Character', 'PlayerControlled'); 76 | this.world.registerSystem('everyframe', ActionSystem); 77 | 78 | this.playerQuery = this.world.createQuery().fromAll('PlayerControlled', 'MoveAction'); 79 | window.addEventListener('keydown', (e) => { 80 | // refresh, because the query is used more than once, and is not a system+persisted query 81 | const entities = this.playerQuery.refresh().execute(); 82 | // maybe your controls move more than one character 83 | for (const player of entities) { 84 | switch (e.code) { 85 | case 'KeyUp': 86 | player.addComponent({ 87 | type: 'ActionMove', 88 | y: -1 89 | }); 90 | break; 91 | case 'KeyDown': 92 | player.addComponent({ 93 | type: 'ActionMove', 94 | y: 1 95 | }); 96 | break; 97 | case 'KeyLeft': 98 | player.addComponent({ 99 | type: 'ActionMove', 100 | x: -1 101 | }); 102 | break; 103 | case 'KeyRight': 104 | player.addComponent({ 105 | type: 'ActionMove', 106 | x: 1 107 | }); 108 | break; 109 | } 110 | } 111 | }); 112 | 113 | window.requestAnimationFrame(this.update.bind(this)); 114 | } 115 | 116 | update(time) { 117 | 118 | window.requestAnimationFrame(this.update.bind(this)); 119 | // in a turn-based game, you might have animations run every frame 120 | // but you might have turn systems run only if there has been user input 121 | // but here we we naively only have one system group and run them all 122 | // every time 123 | this.world.runSystems('everyframe'); 124 | this.world.tick(); 125 | } 126 | } 127 | ``` 128 | 129 | ## Two Ways of Doing Inventory with Entity References 130 | 131 | TODO 132 | 133 | ## Globals and GameLoop 134 | 135 | TODO 136 | 137 | ## Function-Only Systems 138 | 139 | You don't have to use the build in [world.registerSystem](./World.md#registersystem) or [world.runSystems](./World.md#runsystems). 140 | Instead, you can just use a function as a system, run queries within it, and update your entities. 141 | You will not, however, be able to use persistant (index) queries, nor keep track of query changes, as you can with System Queries [system.createQuery](./System.md#createquery). 142 | 143 | ```js 144 | function gravity(world) { 145 | 146 | const frameInfo = world.getEntity('GameLoop') 147 | .getOne('FrameInfo'); 148 | const entities = world.createQuery() 149 | .fromAll('Position', 'Vector') 150 | .execute(); 151 | for (const entity of entities) { 152 | const vector = entity.getOne('Vector'); 153 | vector.y += frameInfo.deltaTime * 9.807; 154 | vector.update(); 155 | } 156 | } 157 | ``` 158 | 159 | ## Turn Ticks vs. Frame ticks 160 | 161 | TODO 162 | 163 | ## ApeDestroy 164 | 165 | TODO 166 | -------------------------------------------------------------------------------- /src/component.js: -------------------------------------------------------------------------------- 1 | const Util = require('./util'); 2 | const idGen = new Util.IdGenerator(); 3 | 4 | class Component { 5 | constructor(world) { 6 | this.world = world; 7 | this._meta = { 8 | key: '', 9 | updated: 0, 10 | entityId: '', 11 | refs: new Set(), 12 | ready: false, 13 | values: {} 14 | }; 15 | } 16 | 17 | preInit(initial) { 18 | return initial; 19 | } 20 | 21 | init(initial) {} 22 | 23 | get type() { 24 | return this.constructor.name; 25 | } 26 | 27 | get key() { 28 | return this._meta.key; 29 | } 30 | 31 | set key(value) { 32 | const old = this._meta.key; 33 | this._meta.key = value; 34 | if (old) { 35 | delete this.entity.c[old]; 36 | } 37 | if (value) { 38 | this.entity.c[value] = this; 39 | } 40 | } 41 | 42 | destroy() { 43 | this.preDestroy(); 44 | this._meta.values = {}; 45 | for (const ref of this._meta.refs) { 46 | const [value, prop, sub] = ref.split('||'); 47 | this.world._deleteRef( 48 | value, 49 | this._meta.entityId, 50 | this.id, 51 | prop, 52 | sub, 53 | this._meta.key, 54 | this.type 55 | ); 56 | } 57 | this.world._sendChange({ 58 | op: 'destroy', 59 | component: this.id, 60 | entity: this._meta.entityId, 61 | type: this.type 62 | }); 63 | this.world.componentsById.delete(this.id); 64 | this.world.componentPool.get(this.type).release(this); 65 | this.postDestroy(); 66 | } 67 | 68 | preDestroy() {} 69 | 70 | postDestroy() {} 71 | 72 | getObject(withIds = true) { 73 | const obj = { 74 | type: this.constructor.name 75 | }; 76 | if (withIds) { 77 | obj.id = this.id; 78 | obj.entity = this.entity.id; 79 | } 80 | let fields = this.constructor.serializeFields || this.constructor.fields; 81 | if (Array.isArray(this.constructor.skipSerializeFields)) { 82 | fields = fields.filter((field, idx, arr) => { 83 | return this.constructor.skipSerializeFields.indexOf(field) === -1; 84 | }); 85 | } 86 | for (const field of fields) { 87 | if ( 88 | this[field] !== undefined && 89 | this[field] !== null && 90 | typeof this[field].getValue === 'function' 91 | ) { 92 | obj[field] = this[field].getValue(); 93 | } else if (this._meta.values.hasOwnProperty(field)) { 94 | obj[field] = this._meta.values[field]; 95 | } else { 96 | obj[field] = this[field]; 97 | } 98 | } 99 | if (this._meta.key) { 100 | obj.key = this._meta.key; 101 | } 102 | return obj; 103 | } 104 | 105 | _setup(entity, initial) { 106 | this.entity = entity; 107 | this.id = initial.id || idGen.genId(); 108 | this._meta.updated = this.world.currentTick; 109 | this._meta.entityId = entity.id; 110 | if (initial.key) { 111 | this.key = initial.key; 112 | } 113 | this._meta.values = {}; 114 | this.world.componentsById.set(this.id, this); 115 | 116 | const fields = this.constructor.fields; 117 | const primitives = this.constructor.primitives; 118 | const factories = this.constructor.factories; 119 | // shallow copy of the property defaults 120 | initial = this.preInit(initial); 121 | const values = Object.assign({}, primitives, initial); 122 | for (const field of fields) { 123 | const value = values[field]; 124 | if (factories.hasOwnProperty(field)) { 125 | const res = factories[field](this, value, field); 126 | if (res !== undefined) { 127 | this[field] = res; 128 | } 129 | } else { 130 | this[field] = value; 131 | } 132 | } 133 | this._meta.ready = true; 134 | Object.freeze(); 135 | this.init(initial); 136 | this.world._sendChange({ 137 | op: 'add', 138 | component: this.id, 139 | entity: this._meta.entityId, 140 | type: this.type 141 | }); 142 | } 143 | 144 | _reset() { 145 | this._meta.key = ''; 146 | this._meta.updated = 0; 147 | this._meta.entityId = 0; 148 | this._meta.ready = false; 149 | this._meta.refs.clear(); 150 | this._meta.values = {}; 151 | } 152 | 153 | update(values) { 154 | if (values) { 155 | delete values.type; 156 | Object.assign(this, values); 157 | if (this.constructor.changeEvents) { 158 | const change = { 159 | op: 'change', 160 | props: [], 161 | component: this.id, 162 | entity: this._meta.entityId, 163 | type: this.type 164 | }; 165 | for (const prop in values) { 166 | change.props.push(prop); 167 | } 168 | this.world._sendChange(change); 169 | } 170 | } 171 | this._meta.updated = this.entity.updatedValues = this.world.currentTick; 172 | } 173 | 174 | _addRef(value, prop, sub) { 175 | this._meta.refs.add(`${value}||${prop}||${sub}`); 176 | this.world._addRef( 177 | value, 178 | this._meta.entityId, 179 | this.id, 180 | prop, 181 | sub, 182 | this._meta.key, 183 | this.type 184 | ); 185 | } 186 | 187 | _deleteRef(value, prop, sub) { 188 | this._meta.refs.delete(`${value}||${prop}||${sub}`); 189 | this.world._deleteRef( 190 | value, 191 | this._meta.entityId, 192 | this.id, 193 | prop, 194 | sub, 195 | this._meta.key, 196 | this.type 197 | ); 198 | } 199 | } 200 | 201 | Component.properties = {}; 202 | Component.serialize = true; 203 | Component.serializeFields = null; 204 | Component.skipSerializeFields = null; 205 | Component.subbed = false; 206 | Component.registered = false; 207 | 208 | module.exports = Component; 209 | -------------------------------------------------------------------------------- /src/entity.js: -------------------------------------------------------------------------------- 1 | const BaseComponent = require('./component'); 2 | const IdGenerator = require('./util').IdGenerator; 3 | const idGen = new IdGenerator(); 4 | 5 | class Entity { 6 | constructor() { 7 | this.types = {}; 8 | this.c = {}; 9 | this.id = ''; 10 | this.tags = new Set(); 11 | this.updatedComponents = 0; 12 | this.updatedValues = 0; 13 | this.destroyed = false; 14 | this.ready = false; 15 | } 16 | 17 | _setup(definition) { 18 | this.destroyed = false; 19 | if (definition.id) { 20 | this.id = definition.id; 21 | } else { 22 | this.id = idGen.genId(); 23 | } 24 | this.world.entities.set(this.id, this); 25 | 26 | this.updatedComponents = this.world.currentTick; 27 | 28 | if (definition.tags) { 29 | for (const tag of definition.tags) { 30 | this.addTag(tag); 31 | } 32 | } 33 | 34 | if (definition.components) { 35 | for (const compdef of definition.components) { 36 | this.addComponent(compdef); 37 | } 38 | } 39 | 40 | if (definition.c) { 41 | const defs = definition.c; 42 | for (const key of Object.keys(defs)) { 43 | const comp = { 44 | ...defs[key], 45 | key 46 | }; 47 | if (!comp.type) comp.type = key; 48 | this.addComponent(comp); 49 | } 50 | } 51 | this.ready = true; 52 | this.world._entityUpdated(this); 53 | } 54 | 55 | has(type) { 56 | if (typeof type !== 'string') { 57 | type = type.name; 58 | } 59 | return this.tags.has(type) || this.types.hasOwnProperty(type); 60 | } 61 | 62 | getOne(type) { 63 | if (typeof type !== 'string') { 64 | type = type.name; 65 | } 66 | let component; 67 | // istanbul ignore else 68 | if (this.types[type]) { 69 | component = [...this.types[type]][0]; 70 | } 71 | return component; 72 | } 73 | 74 | getComponents(type) { 75 | if (typeof type !== 'string') { 76 | type = type.name; 77 | } 78 | return this.types[type] || new Set(); 79 | } 80 | 81 | addTag(tag) { 82 | // istanbul ignore next 83 | if (!this.world.tags.has(tag)) { 84 | throw new Error(`addTag "${tag}" is not registered. Type-O?`); 85 | } 86 | this.tags.add(tag); 87 | this.updatedComponents = this.world.currentTick; 88 | this.world.entitiesByComponent[tag].add(this.id); 89 | if (this.ready) { 90 | this.world._entityUpdated(this); 91 | } 92 | } 93 | 94 | removeTag(tag) { 95 | this.tags.delete(tag); 96 | this.updatedComponents = this.world.currentTick; 97 | this.world.entitiesByComponent[tag].delete(this.id); 98 | this.world._entityUpdated(this); 99 | } 100 | 101 | addComponent(properties) { 102 | const type = properties.type; 103 | const pool = this.world.componentPool.get(type); 104 | if (pool === undefined) { 105 | throw new Error(`Component "${type}" has not been registered.`); 106 | } 107 | const comp = pool.get(this, properties); 108 | if (!this.types[type]) { 109 | this.types[type] = new Set(); 110 | } 111 | this.types[type].add(comp); 112 | this.world._addEntityComponent(type, this); 113 | this.updatedComponents = this.world.currentTick; 114 | if (this.ready) { 115 | this.world._entityUpdated(this); 116 | } 117 | return comp; 118 | } 119 | 120 | removeComponent(component) { 121 | if (typeof component === 'string') { 122 | component = this.c[component]; 123 | } 124 | if (component === undefined) { 125 | return false; 126 | } 127 | if (component.key) { 128 | delete this.c[component.key]; 129 | } 130 | this.types[component.type].delete(component); 131 | 132 | if (this.types[component.type].size === 0) { 133 | delete this.types[component.type]; 134 | } 135 | this.world._deleteEntityComponent(component); 136 | this.world._entityUpdated(this); 137 | component.destroy(); 138 | return true; 139 | } 140 | 141 | getObject(componentIds = true) { 142 | const obj = { 143 | id: this.id, 144 | tags: [...this.tags], 145 | components: [], 146 | c: {} 147 | }; 148 | for (const type of Object.keys(this.types)) { 149 | for (const comp of this.types[type]) { 150 | // $lab:coverage:off$ 151 | if (!comp.constructor.serialize) { 152 | continue; 153 | } 154 | // $lab:coverage:on$ 155 | if (comp.key) { 156 | obj.c[comp.key] = comp.getObject(componentIds); 157 | } else { 158 | obj.components.push(comp.getObject(componentIds)); 159 | } 160 | } 161 | } 162 | return obj; 163 | } 164 | 165 | destroy() { 166 | 167 | if (this.destroyed) return; 168 | if (this.world.refs[this.id]) { 169 | for (const ref of this.world.refs[this.id]) { 170 | const [entityId, componentId, prop, sub] = ref.split('...'); 171 | const entity = this.world.getEntity(entityId); 172 | // istanbul ignore next 173 | if (!entity) continue; 174 | const component = entity.world.componentsById.get(componentId); 175 | // istanbul ignore next 176 | if (!component) continue; 177 | const path = prop.split('.'); 178 | 179 | let target = component; 180 | let parent = target; 181 | for (const prop of path) { 182 | parent = target; 183 | target = target[prop]; 184 | } 185 | if (sub === '__set__') { 186 | target.delete(this); 187 | } else if (sub === '__obj__') { 188 | delete parent[path[1]]; 189 | } else { 190 | parent[prop] = null; 191 | } 192 | } 193 | } 194 | for (const type of Object.keys(this.types)) { 195 | for (const component of this.types[type]) { 196 | this.removeComponent(component); 197 | } 198 | } 199 | this.tags.clear(); 200 | this.world.entities.delete(this.id); 201 | delete this.world.entityReverse[this.id]; 202 | this.destroyed = true; 203 | this.ready = false; 204 | this.world.entityPool.destroy(this); 205 | this.world._clearIndexes(this); 206 | } 207 | } 208 | 209 | module.exports = Entity; 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ape-ECS 2 | ![Ape-ECS Hero](https://raw.githubusercontent.com/fritzy/ape-ecs/master/imgs/ape_ecs900wbg.png) 3 | 4 | [![npm](https://img.shields.io/npm/v/ape-ecs)](https://www.npmjs.com/package/ape-ecs) 5 | [![Build Status](https://travis-ci.com/fritzy/ape-ecs.svg?branch=master)](https://travis-ci.com/fritzy/ape-ecs) 6 | ![Coveralls github](https://img.shields.io/coveralls/github/fritzy/ape-ecs) 7 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/fritzy/ape-ecs/pulls) 8 | 9 | [![chat on discord](https://img.shields.io/discord/782736130550267948?label=chat&logo=discord&color=blue)](https://discord.gg/hdbdueTDJk) 10 | [![@fritzy twitter](https://img.shields.io/badge/@fritzy-twitter-blue?logo=twitter&color=blue)](https://twitter.com/fritzy) 11 | 12 | A performant, featureful, and flexible [Entity-Component-System](https://en.wikipedia.org/wiki/Entity_component_system) library for JavaScript, written in ECMAScript ES2018, intended for use in games and simulations. 13 | 14 | ## Documentation 15 | * [Overview](https://github.com/fritzy/ape-ecs/blob/master/docs/Overview.md) 16 | * [API Reference](https://github.com/fritzy/ape-ecs/blob/master/docs/API_Reference.md) 17 | * [Patterns](https://github.com/fritzy/ape-ecs/blob/master/docs/Patterns.md) 18 | * [1.0 Announcement Post](https://dev.to/fritzy/introducing-ape-ecs-js-250o) 19 | * [Changelog](https://github.com/fritzy/ape-ecs/blob/master/CHANGELOG.md) 20 | * [Discord -- Web Platform Game Dev](https://discord.gg/hdbdueTDJk) 21 | * [@fritzy Twitter](https://twitter.com/fritzy) 22 | 23 | ## Install 24 | 25 | ```sh 26 | npm install ape-ecs 27 | ``` 28 | 29 | ## Differentiating Features 30 | 31 | * Advanced Queries for entities. 32 | * Persisted Queries (indexes) are updated as Entity composition changes. 33 | * Component reference properties to Entities (EntityRef, EntitySet, EntityObject) 34 | * When a referenced entity is destroyed, the property is updated to null. 35 | * Subscribe-able events for adding and removing references. 36 | * Reverse query from entity to entity-components that reference it. 37 | * Not all systems need to run every frame. 38 | * Export/import support for saving/restoring state with component-level serialization configuration. 39 | * 100% Test Coverage. 40 | 41 | ## Example 42 | 43 | ```js 44 | const ApeECS = require('ape-ecs'); 45 | 46 | class Gravity extends ApeECS.System { 47 | init() { 48 | this.mainQuery = this.createQuery().fromAll('Position', 'Physics'); 49 | } 50 | 51 | update(tick) { 52 | const entities = this.mainQuery.execute(); 53 | const frameInfo = this.world.getEntity('frame'); 54 | for (const entity of entities) { 55 | const point = entity.getOne('Position'); 56 | if (!entity.has('Vector')) { 57 | entity.addComponent({ 58 | type: 'Vector', 59 | mx: 0, 60 | my: 0 61 | }) 62 | } 63 | const vector = entity.getOne('Vector'); 64 | vector.my += 9.807 * frame.time.deltaTime * .01; 65 | vector.update(); 66 | } 67 | } 68 | } 69 | 70 | class Position extends ApeECS.Component {} 71 | Position.properties = { 72 | x: 0, 73 | y: 0 74 | }; 75 | 76 | class Vector extends ApeECS.Component { 77 | 78 | get speed() { 79 | return Math.sqrt(this.mx**2 + this.my**2); 80 | } 81 | } 82 | Vector.properties = { 83 | mx: 0, 84 | my: 0, 85 | speed: 0 86 | }; 87 | 88 | class FrameInfo extends ApeECS.Component {} 89 | FrameInfo.properties = { 90 | deltaTime: 0, 91 | deltaFrame: 0, 92 | time: 0 93 | }; 94 | 95 | const world = new ApeECS.World(); 96 | world.registerComponent(Position); 97 | world.registerComponent(Vectory); 98 | world.registerComponent(FrameInfo); 99 | world.registerTags('Physics'); 100 | world.registerSystem('frame', Gravity); 101 | 102 | const frame = world.createEntity({ 103 | id: 'frame', 104 | c: { 105 | time: { 106 | type: 'FrameInfo', 107 | } 108 | } 109 | }) 110 | 111 | // see world.creatEntity and world.createEntities 112 | // in docs/World.md for more details 113 | world.registerSystem('frame', require('./move.js')); 114 | world.createEntities(require('./saveGame.json')); 115 | 116 | let lastTime = 0; 117 | 118 | function update(time) { 119 | const delta = time - lastTime; 120 | time = lastTime; 121 | frame.time.update({ 122 | time: time, 123 | deltaTime: delta, 124 | deltaFrame: delta / 16.667 125 | }); 126 | world.runSystems('frame'); 127 | // run update again the next browser render call 128 | // every 16ms or so 129 | window.requestAnimationFrame(update); 130 | } 131 | update(0); 132 | ``` 133 | 134 | ## More About ECS 135 | 136 | The Entity-Component-System paradigm is great for managing dynamic objects in games and simulations. Instead of binding functionality to data through methods, entities are dynamically composed of any combination of types. Separate systems are then able to query for entities with a given set of types. 137 | 138 | ECS's dynamic data composition and freely interacting systems leads to: 139 | * More complex and dynamic composition than OOP 140 | * Improved performance due to lack of API methods 141 | * [Emergent Gameplay](https://en.wikipedia.org/wiki/Emergent_gameplay) with logical behavior extended beyond the programmer's vision. 142 | 143 | This library has been inspired in part by: 144 | * [Overwatch Gameplay Architecture and Netcode](https://www.youtube.com/watch?v=W3aieHjyNvw) 145 | * [Mozilla ECSY](https://blog.mozvr.com/introducing-ecsy/) 146 | 147 | ## Example Game 148 | 149 | An in-progress arcade game [Missile Orders](https://github.com/fritzy/missileorders.git). 150 | 151 | This game is not in a complete state yet, nor does it show off all of the potential of ECS yet. 152 | 153 | ## Running the Tests 154 | 155 | The goal is to keep test coverage at 100%. 156 | 157 | ```sh 158 | git clone git@github.com/fritzy/ape-ecs.git 159 | cd ape-ecs 160 | npm install 161 | npm test 162 | ``` 163 | ## Contributors 164 | * [Ben Morse](https://twitter.com/benathon) -- Ben is an early adopter that provided a lot of insight and you have him to thank for the TypeScript definitions! Ben has a game, [Super Game of Life](https://github.com/esromneb/SuperGameOfLife) that uses Ape ECS. 165 | 166 | ## Special Thanks 167 | * [Jaime Robles](https://twitter.com/DrawnByJaime) -- For the Ape ECS banner! 168 | -------------------------------------------------------------------------------- /docs/Entity.md: -------------------------------------------------------------------------------- 1 | # Entity 2 | 3 | Entities are collections of `Component` and tags, with their own id. 4 | They are the main unit in your game/simulation, and can be dynamically constructed and changed. 5 | 6 | You can create entities with the factories [world.createEntity](./World.md#createentity) and [world.createEntities](./World.md#createentities). 7 | 8 | Your results from a [Query](./Query.md) will be a `Set` of `Entities`. 9 | 10 | You can check to see if a given entity has at least one of a given `Component` type or `tag` with [entity.has](#has). 11 | You can iterate through all of the instance of a given `Component` type within an entity with the [types property](#types). Any `Component` that has its [key property](./Component.md#key) set will be available as a property directly on the `Entity` instance. 12 | 13 | At any point, you can dyamically [entity.addComponent](#addcomponent) and [entity.removeComponent](#removecomponent) at any point. 14 | 15 | ```js 16 | world.registerComponent('Position', { 17 | properties: { 18 | x: 0, 19 | y: 0 20 | } 21 | }); 22 | 23 | const entity = world.createEntity({ 24 | c: { 25 | position: { 26 | type: 'Position', 27 | x: 20, 28 | y: 7 29 | } 30 | } 31 | }) 32 | 33 | console.log(entity.has('Position')); // true 34 | ``` 35 | 36 | ## creating 37 | 38 | You create `Entity` instances through factory functions. 39 | 40 | 👀 See [world.createEntity](./World.md#createentity) and [world.createEntities](./World.md#createentities). 41 | 42 | ## id 43 | 44 | The `id` property of an `Entity` may have been specified upon creation, but is generally auto-generated. 45 | 46 | ⚠️ Do not reassign the id of an `Entity` or `Component` after they have been created. Here there be dragons. 🐉 47 | 48 | ## types 49 | 50 | The `types` property is an Object with the key indicating the `Component` types that are attached to this `Entity`. 51 | Each value is a `Set` of `Component` instances. 52 | 53 | ```js 54 | for (const ctype of Object.keys(entity.types)) { 55 | for (const component of entity.types[ctype]) { 56 | console.log(`Type <${ctype}> Id: ${component.id}`); 57 | } 58 | } 59 | ``` 60 | 61 | ## has 62 | 63 | The `has` method returns a `boolean` based on whether the `Entity` has a `Component` of that type or a tag of that name. 64 | 65 | ```js 66 | entity.has('Point'); // true or false 67 | ``` 68 | 69 | **Arguments**: 70 | * type: `String`, _required_ 71 | 72 | **Returns**: `Boolean` 73 | 74 | ## getComponents 75 | 76 | Returns a `Set` of `Components` of a given type. 77 | 78 | ```js 79 | const pointsSet = entity.getComponents('Point'); 80 | ``` 81 | 82 | ## getOne 83 | 84 | Returns the first `Component` of a given type from an `Entity` or `undefined`. 85 | 86 | ```js 87 | const point = entity.getOne('Point'); 88 | ``` 89 | 90 | 👆 If you only intend to have 1 of given `Component` type on an `Entity`, you might consider using a [key](./Component.md#key) value. 91 | 92 | ## addTag 93 | 94 | Adds a tag to the `Entity`. 95 | 96 | ```js 97 | entity.addTag('Invisible'); 98 | ``` 99 | 100 | ⚠️ Tags must be registered with [world.addTag](./World.md#addtag) before use. 101 | 102 | ## removeTag 103 | 104 | Removes a tag frmo the `Entity`. 105 | 106 | ```js 107 | entity.removeTag('Invisible'); 108 | ``` 109 | 110 | ## addComponent 111 | 112 | Creates a new `Component` instance of a given type and adds it to the `Entity`. 113 | 114 | ```js 115 | entity.addComponent({ 116 | type: 'Point', //required 117 | key: 'point', //optional 118 | id: 'asdf-1' //optional -- don't do this unless you're restoring 119 | x: 123, // set the initial values of properties previously registered 120 | y: 321 121 | // feel free to not set all of the properties for defaults 122 | }); 123 | ``` 124 | 125 | Setting a key makes the `Component` instance accessible as a property of the `Entity`. 126 | 127 | 💭 It can sometimes be useful to set a custom id for an `Entity`, but there may not be a valid usecase for a new `Component`. You should generally only specify the `id` in `addComponent` if you're restoring a previous `Component` from `getObject`. 128 | 129 | 👀 See [world.createEntity](./World.md#createEntity) for another perspective on `Component` instance definitions. 130 | 131 | ## removeComponent 132 | 133 | Removes and destroys an existing `Component` instance from the `Entity`. 134 | 135 | ```js 136 | entity.removeComponent('someId'); 137 | ``` 138 | 139 | ```js 140 | for (const component of entity.getComponents('Buff')) { 141 | entity.removeComponent(component); 142 | // alternatively, component.destroy(); 143 | } 144 | ``` 145 | 146 | **Arguments:** 147 | * id/component: `String` of the component id or `Component` instance. 148 | 149 | ## getObject 150 | 151 | Generates a serializable `Object` of the `Entity` instance, including all of its `Components`. 152 | 153 | ```js 154 | const obj = entity.getObject(); 155 | 156 | console.log(obj) 157 | ``` 158 | ```js 159 | { 160 | id: 'lkjsadf-34', 161 | tags: ['Visible'], 162 | components: [ //anything without a key will be in components 163 | { 164 | id: 'fffff-39', 165 | type: 'Buff', 166 | name: 'Stone Skin', 167 | stat: 'armor', 168 | amount: 5 169 | }, 170 | { 171 | id: 'fffff-40', 172 | type: 'Buff', 173 | stat: 'charisma', 174 | name: 'Nice Haircut', 175 | amount: 1 176 | }, 177 | { 178 | id: 'qeruj-9', 179 | type: 'Sprite', 180 | texture: 'assets/sprites/mean-face.png', 181 | pixiSprite: null 182 | } 183 | ], 184 | c: { //anything will a key will end up under c 185 | point: { 186 | id: 'asdlfkj-9334', 187 | type: 'Point', 188 | x: 34, 189 | y: 109 190 | } 191 | } 192 | } 193 | ``` 194 | 195 | The returning object includes the `Entity` id, tags, array of Components, and an object called "c" with keyed `Components`. 196 | 197 | 👆 You can use this resulting Object to save state and restore with `world.createEntity` later. 198 | 199 | 👀 See also [world.getObject](World.md#getobject) and [component.getObject](./Component.md#getobject). 200 | 201 | 💭 `world.getObject` calls this method on all entities to generate its result. This method calls all of its `Components` `getObject` to get its result. 202 | 203 | ## destroy 204 | 205 | Destroys an `Entity` and all of its `Components`. 206 | 207 | ```js 208 | entity.destroy(); 209 | ``` 210 | -------------------------------------------------------------------------------- /docs/System.md: -------------------------------------------------------------------------------- 1 | # System 2 | 3 | Systems are where the work happens in ECS. 4 | 5 | ### Index: 6 | * [Creating](#creating) 7 | * [init](#init) 8 | * [world](#world) 9 | * [update](#update) 10 | * [createQuery](#createquery) 11 | * [subscribe](#subscribe) 12 | * [Registering and Running](#registering-and-running) 13 | * [Tracking Query Changes](#tracking-query-changes) 14 | 15 | ## Creating 16 | 17 | ```js 18 | class Gravity extends ApeECS.System { 19 | 20 | init(gravityUp) { 21 | // We're going to want a query that gives us Entitys that must have all of these Components at least. 22 | // We want it to be kept up to date, so we persist it. 23 | this.massesQuery = this.createQuery().fromAll('Position', 'Movement', 'Mass').persist(); 24 | // Let's pretend we have an Entity with the id 'Frame' with a Component 25 | // with a key called 'frameInfo' that has the deltaTime as property. 26 | // Cool. 27 | this.frameInfo = this.world.getEntity('Frame').frameInfo; 28 | this.gravityUp = !!gravityUp; 29 | } 30 | 31 | update(currentTick) { 32 | const entities = this.massesQuery.execute(); // get latest query results 33 | for (const entity of entities) { 34 | const position = entity.getOne('Position'); // we only expect to have one of these 35 | const movement = entity.getOne('Movement'); 36 | if(this.gravityUp) { 37 | position.y -= movement.y; 38 | } else { 39 | position.y += movement.y; 40 | } 41 | movement.x += 9.807 * this.frameInfo.deltaTime; 42 | } 43 | } 44 | } 45 | 46 | // Add Gravity to the 'EveryFrame' group -- a set of Systems that run every rendering frame. 47 | // We made up the name 'EveryFrame'. 48 | world.registerSystem('EveryFrame', Gravity, [false]); 49 | ``` 50 | 51 | ## init 52 | 53 | Method that runs when the System is first set up. It's meant for you to override and a good place to set up your queries, any subscriptions, and grab any evergreen `Entities`. 54 | 55 | ```js 56 | class MoveTurn extends System { 57 | init() { 58 | this.moveActionsQuery = this.createQuery().fromAll('Character', 'MoveAction', 'Position').persist(); 59 | this.map = this.world.getEntity('Map'); 60 | } 61 | // ... 62 | } 63 | ``` 64 | 65 | 💭 Yeah, you could override the constructor and pass the arguments of to the super, but this approach is more stable across versions. 66 | 67 | ## world 68 | 69 | This property is the world the System was registered in. 70 | 71 | ## update 72 | 73 | The update method should be overridden. When the system is ran, it runs this method. 74 | 75 | ```js 76 | class MoveTurn extends System { 77 | //... 78 | update(currentTick) { 79 | const characters = this.moveActionsQuery.execute(); 80 | for (const character of characters) { 81 | for (const action of character.types['MoveAction']) { 82 | const newPos = { 83 | x: character.position.x + action.vector.x, 84 | y: character.position.y + action.vector.y 85 | }; 86 | const coord = `${newPos.x}x${newPos.y}`; 87 | if (this.map.mainLayer.tiles.hasOwnProperty(coord)) { 88 | const tile = this.map.mainLayer.tiles[coord]; 89 | if (!tile.has('Blocked')) { 90 | character.addComponent({ 91 | type: 'MoveAnimation', 92 | ox: character.x, 93 | oy: character.y, 94 | nx: newPos.x, 95 | ny: newPos.y, 96 | startTick: currentTick 97 | }); 98 | character.position.x = newPos.x; 99 | character.position.y = newPos.y; 100 | } 101 | } 102 | character.removeComponent(action); 103 | } 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | **Arguments**: 110 | * currentTick: `Number`, integer, is passed to your update function when run. `world.currentTick`. 111 | 112 | 👆 The `system.changes` array is cleared after every time the update function runs, so you if you don't deal with the changes within your update function every time, you might miss some. 113 | 114 | 👆 Any persisted `Queries` created within your System will have their changes cleared after every time the `System` is run. 115 | 116 | ## createQuery 117 | 118 | This method is a `Query` factory. 119 | 120 | ```js 121 | class MoveTurn extends System { 122 | init() { 123 | this.moveActionsQuery = this.createQuery().fromAll('Character', 'MoveAction', 'Position').persist(); 124 | } 125 | } 126 | ``` 127 | 128 | **Arguments**: 129 | * init: `Object`, _optional_, 👀 See the [Query Docs](./Query.md) 130 | 131 | 👀 You can also createQueries with the [world.createQuery factory method](./World.md#createquery) but you can not persist those queries or track their changes. 132 | 133 | ## subscribe 134 | 135 | Subscribe a `System instance` to events from a given `Component` type. 136 | 137 | ```js 138 | class MySystem extends ApeECS.System { 139 | init() { 140 | this.subscribe(type); 141 | } 142 | } 143 | ``` 144 | 145 | ### Arguments: 146 | * type: `String`, _required_, registered `Component` type 147 | 148 | Every time the systems is run, the `system.changes` array is updated with all of the events since last time the System ran. 149 | Any change events that happen during the System update will be in the next run of the System. 150 | 151 | ### Event operations 152 | 153 | #### add 154 | 155 | When a Component is added. 156 | 157 | ```js 158 | { 159 | op: 'add', // operation 160 | component: 'asdlfkj-234', // component id 161 | entity: 'jfjfjf-2', // entity id 162 | type: 'SomeComponent' 163 | } 164 | ``` 165 | 166 | #### destroy 167 | 168 | When a Component is destroyed. 169 | ```js 170 | { 171 | op: 'destroy', // operation 172 | component: 'asdlfkj-234', // component id 173 | entity: 'jfjfjf-2', // entity id 174 | type: 'SomeComponent' 175 | } 176 | ``` 177 | 178 | 👆 The Component has been destroyed, so you won't be able to `world.getComponent(id)` 179 | 180 | #### change 181 | 182 | When properties are updated. 183 | ```js 184 | { 185 | op: 'change', // operation 186 | props: ['x', 'image', 'y'], // list of properties changed 187 | component: 'asdlfkj-234', // component id 188 | entity: 'jfjfjf-2', // entity id 189 | type: 'SomeComponent' 190 | } 191 | ``` 192 | 193 | Only happen under certain conditions: 194 | * When the component has the static property `changeEvents = true`. 195 | * When the component update() function is run. 196 | * Only includes the properties if they were updated via update() via the optional values Object. 197 | 198 | #### addRef 199 | 200 | When an `EntityRef`, `EntitySet`, or `EntityObject` has an entity assigned. 201 | 202 | ```js 203 | { 204 | op: 'addRef', // operation 205 | component: 'asdlfkj-234', // component id 206 | entity: 'jfjfjf-2', // entity id 207 | type: 'SomeComponent', 208 | target: '3r4124-33', // referenced entity id 209 | property: 'slot', // property of the component that has been assigned the target 210 | } 211 | ``` 212 | 213 | #### deleteRef 214 | 215 | When an `EntityRef`, `EntitySet`, or `EntityObject` has an entity unassigned or deleted. 216 | 217 | ```js 218 | { 219 | op: 'deleteRef', // operation 220 | component: 'asdlfkj-234', // component id 221 | entity: 'jfjfjf-2', // entity id 222 | type: 'SomeComponent', 223 | target: '3r4124-33', // referenced entity id 224 | property: 'slot', // property of the component that had been assigned the target 225 | } 226 | ``` 227 | 228 | ## Registering and Running 229 | 230 | 👀 See [world.registerSystem](./World.md#registersystem) for information on creating new Systems and registering them with the world. 231 | 232 | ## Tracking Query Changes 233 | -------------------------------------------------------------------------------- /docs/Overview.md: -------------------------------------------------------------------------------- 1 | # Ape ECS Overview 2 | 3 | * [API Reference](./API_Reference.md) 4 | * [Patterns](./Patterns.md) 5 | * [Back to the README](../README.md) 6 | 7 | ## What is ECS (Entity-Component-System)? 8 | 9 | This ground is well covered with a quick Internet search, but what you'll quickly discover is that when most articles describe ECS, they're describing a specific implementation. 10 | Even the [Wikipedia Article](https://en.wikipedia.org/wiki/Entity_component_system) does this to some extent. 11 | 12 | ECS is a paradigm for managing unencapsulated data in a game or simulation, enabling dynamic composition by combining types, and keeping game logic all within systems. 13 | Each data type is a structure of values, and each instance has a unique identifier and the identifier of an entity. 14 | Systems can query or filter for the entities they need by their composition, and apply their logic to this. 15 | 16 | This de-encapusulation can be thought of as anti-Object-Oriented-Programming, and it kind of is, but that doesn't mean the implementation doesn't use classes for its Components, Systems, and Entities (some do, including Ape ECS). 17 | 18 | This is all very abstract, because it's a paradigm, a way of organizing data & code, and not a specific thing. 19 | It all comes down to the definitions: 20 | 21 | * Components: Data types, kind of like structs, usually. Instances have a unique id. 22 | * Entities: The association of Component instances to an Entity id. In a simple implemention, an Entity is _only_ an id. 23 | * Systems: Functions that filter down Entities to just the ones that they should apply to, and then applies their logic. 24 | 25 | ## Advantages of ECS 26 | 27 | * allows for dynamic mixing of types 28 | * runtime mutations of types 29 | * organize your logic around queries that gather relevant data rather than spread between many classes and methods 30 | * data is already separated, easy to serialize. 31 | * faster performance of many objects 32 | * tag your data for easy inclusion or exclusion from systems 33 | * system-integrated data creates emergent gameplay/simulation 34 | 35 | ## How does Ape ECS implement ECS? 36 | 37 | ### Ape ECS Has Worlds 38 | 39 | Ape ECS has [a World class](./World.md) as a registry for Components, Entities, Systems, and Queries. You can register Component classes, System classes, create Entity instances (which in turn create Component instances), retrieve Entities and Components by id, run Systems, and create Queries. 40 | 41 | ### Ape ECS has Components 42 | 43 | Ape ECS [Components](./Component.md) are extended from `ApeECS.Component` to define your properties and lifecycle methods and registered with a World. 44 | Components are created when you create an Entity or added to an Entity later. 45 | Components can be destroyed directly or by being removed from an Entity (effecively the same thing). 46 | 47 | Components can return a JSON-like Object with `component.getObject` that matches the format for `entity.addComponent` and `world.createEntity`, making them easy to serialize, store, and restore. 48 | 49 | ### Ape ECS has Entities 50 | 51 | Ape ECS [Entities](./Entity.md) can be created from a World instance, either with a manual or auto-generated id. 52 | Within the creation factories, Component instances must be defined with initial values; any Entity that doesn't have any Components is destroyed. 53 | 54 | Entities can also have Tags, which are like Components, they're just the types. 55 | Unlike Components, a Tag is just a string, and Entity can only have one of the same type at at time. 56 | Tags and Component types are not distinguished between each other for Queries or `entity.has(type)`. 57 | 58 | Components can be retrieved from Entities with `entity.getComponents(type)` or `entity.types[type]`, each retrieving a `Set` of component instances. 59 | Tags are within an `entity.tags` `Set`. 60 | 61 | ### Ape ECS has Systems 62 | 63 | Ape ECS [Systems](./System.md) are extended from `ApeECS.System`, typically with an overridden `init()` and `update(tick)` method. 64 | You should set up all of your queries in `init()` and do your system logic on the resulting entities in your `update()` method. 65 | 66 | Systems also manage [Queries](./Query.md) that are created from them if persisted. Change feeds and results are updated automatically before the system is run and cleaned up afterward. 67 | 68 | ### Ape ECS has Queries 69 | 70 | Ape ECS has fairly advanced [Queries](./Query.md). Like most ECS systems, you can query for Entities that have at least a subset of types and tags. Additionly you can add results from Entities that have at least one of a subset (logical "any" rather than "all"). Furthermore you can filter results with exclusion (`.not()`), and filter down to results that must have at least one of (`.only()`). 71 | 72 | If Queries are created within a System with `System.createQuery()` they can be persisted and tracked for changes. You can further filter the result set by which tick their component/tag structure was last changed, or when they had component values that changed. 73 | 74 | [Entity References](./Entity_Refs.md) allow you to start with an Entity and query for any other entities that have a given type that references them. 75 | 76 | ### Other Ape ECS Features 77 | 78 | [Entity References](./Entity_Refs.md) allow for dynamically maintained links between entities, and queries by reference. 79 | 80 | [ApeDestroy](./World.md) is a tag that can be added to any entity that automatically cleans them up at the end of a world tick, and filters them from queries in the meantime (although you can override this per query). This is a common ECS lifecycle pattern. 81 | 82 | ## Performance 83 | 84 | Right now, we've got benchmarks for creating, editing, pooling, and destroying entities and components. 85 | 86 | Windows 10 Home (version 2004) WSL2 Ubuntu 20.04 on AMD Ryzen 5 3600 @ 3.6Ghz 87 | 88 | ``` 89 | ape-ecs % node benchmark.js 90 | Creating and destroying 50000 entities... 91 | Create 50,000 entities with two simple components : 191.88ms 92 | Changing the values of each component : 4.70ms 93 | Destroy 50,000 entities with two simple components: 156.16ms 94 | Recreating components now that pool is established: 149.00ms 95 | ``` 96 | 97 | Results on a 2.9 GHz 6-Core Intel Core i9 2018 MacBook Pro 98 | 99 | ``` 100 | Creating and destroying 50000 entities... 101 | Create 50,000 entities with two simple components : 175.81ms 102 | Changing the values of each component : 5.30ms 103 | Destroy 50,000 entities with two simple components: 171.25ms 104 | Recreating components now that pool is established: 162.18ms 105 | ``` 106 | 107 | Google Chrome on MacBook Pro Version 86.0.4240.111 (Official Build) (x86\_64) 108 | ``` 109 | ape-ecs % npm run webbench 110 | Creating and destroying 50000 entities... 111 | Create 50,000 entities with two simple components : 187.64ms 112 | Changing the values of each component : 9.02ms 113 | Destroy 50,000 entities with two simple components: 176.78ms 114 | Recreating components now that pool is established: 140.20ms 115 | ``` 116 | 117 | 📝 A previous version of this benchmark of the web benchmark was erroneously generating 5 million entities instead 50,0000, which blew the numbers up by more than 10x due to aggressive garbage collection. 118 | 119 | Peformance is a strong priority for Ape ECS, so we'll continue to create more benchmarks, optimizations, and usage patterns. 120 | 121 | ## Future 122 | 123 | Here's some of the things we're thinking about for the future. 124 | We'd love to review your pull requests for these things. 125 | 126 | * Query optimizations (Bitmask?) 127 | * Benchmarks and optimization for Queries 128 | * Network support (commands and rewind). 129 | * More patterns and Examples 130 | 131 | We'd also love to see pull requests for improving the docs. 132 | Grammar errors, inconsistencies, and inaccuracies all need to be fixed! 133 | -------------------------------------------------------------------------------- /docs/Query.md: -------------------------------------------------------------------------------- 1 | # Query 2 | 3 | Queries are the primary way of retrieving data in ApeECS. 4 | Retrieve Entities based on Component composition, specific sets, and reverse references. 5 | Most ECS implementations implement a Union query, which ApeECS does through it's `query.fromAll()` method. 6 | 7 | ```js 8 | // find all of the entities with both Mass and Point Components/Tags 9 | const entities = world.createQuery().fromAll('Mass', 'Point').execute(); 10 | ``` 11 | 12 | 👆 A Queries must include at least one from* method call or init option. 13 | 14 | 👆 `fromAll`, `fromAny`, `fromReverse`, `from`, `not`, `only`, `persist` can all be done in the creation factory's init `Object`. 15 | 16 | 👆 `from*`, `not`, and `only` methods do not distinguish between Component types and Entity tags. 17 | 18 | ```js 19 | // functionally equivalent to the previous example 20 | const entities = world.createQuery({ all: ['Mass', 'Point'] }).execute(); 21 | ``` 22 | 23 | ## create 24 | 25 | There are two Query factories -- [world.createQuery](./World.md#createquery) and [system.createQuery](./System.md#createquery). 26 | Each returns a Query instance. 27 | The main difference is that a Query created from a System is associated with that system, and thus can be persisted to track changes. 28 | 29 | A common pattern is to create your persisted queries in a System init. 30 | 31 | ```js 32 | class ApplyMove extends ApeECS.System { 33 | 34 | init() { 35 | this.moveQuery = this.createQuery().fromAll('Sprite', 'Position', 'MoveAction').persist(); 36 | } 37 | 38 | update(tick) { 39 | for (const entity of this.moveQuery.execute()) { 40 | for (const action of entity.getComponents('MoveAction')) { 41 | entity.c.Position.x += action.x; 42 | entity.c.Position.y += action.y; 43 | entity.removeComponent(action); 44 | } 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | ### Query init object 51 | 52 | You can pass an init object to world or system `createQuery({})` 53 | 54 | * init 55 | * from: `Array`, equiv to from() 56 | * all: `Array`, equiv to fromAll() 57 | * any: `Array`, equiv to fromAny() 58 | * not `Array`, equiv to not() 59 | * only: `Array`, equiv to only() 60 | * persist: `bool`, equiv to persist() 61 | * trackAdd: `bool`, track added entities between system runs on persisted query with the query.added Set. 62 | * trackRemoved: `bool`, track removed entities between system runs on persisted query with query.removed Set. 63 | * includeApeDestroy: `bool`, if world.config.useApeDestoy is true, queries default to removing Entities with the `ApeDestroy` tag, but you can include them by setting this to `true`. 64 | 65 | ## fromAll 66 | 67 | Using `fromAll` adds to the Query execution results Entities with at least all of the Component types/Tags listed. 68 | It is literally a set union. 69 | 70 | **Arguments**: 71 | * ...types: `[]String|Component class`: _required_, array of strings that are the tags and Component types you require from an entity 72 | 73 | **Returns**: 74 | * `Query` instance for chaining methods. 75 | 76 | ```js 77 | const query = world.createQuery().fromAll('Sprite', 'Position', 'MoveAction'); 78 | ``` 79 | 80 | ```js 81 | const query = world.createQuery({ 82 | all: ['Sprite', 'Position', 'MoveAction'] 83 | }); 84 | ``` 85 | 86 | ## fromAny 87 | 88 | Query `execute` results include Entities with at least one of the tags or Component types listed. 89 | 90 | ```js 91 | //must have Character Component type or tag and must have one or more of Sprite, Image, or New. 92 | const query = world.createQuery().fromAll('Character').fromAny('Sprite', 'Image', 'New'); 93 | ``` 94 | 95 | ```js 96 | const query = world.createQuery({ 97 | all: ['Character'], 98 | any: ['Sprite', 'Image', 'New'] 99 | }); 100 | ``` 101 | 102 | **Arguments**: 103 | * ...types: `[]String|Component class`: _required_, array of strings that are the tags and Component types you require at least one of from an entity 104 | 105 | **Returns**: 106 | * `Query` instance for chaining methods. 107 | 108 | ## fromReverse 109 | 110 | Query `execute` results must include entities that have Components that reference a given entity with a given Component type. 111 | 112 | **Arguments**: 113 | * entity: `Entity`, _required_, Entity instance that must be refered to by a Component. 114 | * type: `String`, _required_, Component type that contains the reference to the entity. 115 | 116 | **Returns**: 117 | * `Query` instance for chaining methods. 118 | 119 | ```js 120 | // get all of the entities that have a component indicating that they're in the player's inventory 121 | const entities = world.createQuery().fromReverse(player, 'InInventory').execute(); 122 | ``` 123 | ```js 124 | const query = world.createQuery({ 125 | reverse: { 126 | entity: player, 127 | type: 'InInventory' 128 | } 129 | }); 130 | ``` 131 | 132 | ## from 133 | 134 | Limit the Query `execute` results to only include a subset of these specified entities. 135 | 136 | **Arguments**: 137 | * ...entities: `[]Entity`, _required_, Array of entity lists that is a superset of the results 138 | 139 | **Returns**: 140 | * `Query` instance for chaining methods. 141 | 142 | ```js 143 | const query = world.createQuery().from([player, enemy1]); 144 | ``` 145 | 146 | ```js 147 | const query = world.createQuery({ 148 | from: [player, enemy1] 149 | }); 150 | ``` 151 | 152 | ## not 153 | 154 | Limit Query `execute` results to not include Entities that have any of these Component types or tags. 155 | `not()` filters results, and thus the query must include at least one of `from`, `fromAll` or `fromAny` as well. 156 | 157 | **Arguments**: 158 | * ...types: `[]String|Component class`, _required_, Array of Component types and Tags to disqualify result entities 159 | 160 | **Returns**: 161 | * `Query` instance for chaining methods. 162 | 163 | ```js 164 | const query = world.createQuery().fromAll('Character', 'Sprite').not('Invisible', 'MarkedForRemoval'); 165 | ``` 166 | 167 | ```js 168 | const query = world.createQuery({ 169 | all: ['Character', Sprite], 170 | not: ['Invisible', 'MarkedForRemoval'] 171 | }); 172 | ``` 173 | 174 | ## only 175 | 176 | Limit Query `execute` results to only include Entities that have at least one of these Component types or tags. 177 | `only()` filters results, and thus the query must include at least one of `from`, `fromAll` or `fromAny` as well. 178 | 179 | **Arguments**: 180 | * ...types: `[]String|Component class`, _required_, Array of Component types and Tags to disqualify result entities 181 | 182 | **Returns**: 183 | * `Query` instance for chaining methods. 184 | 185 | ```js 186 | const query = world.createQuery().fromAll('Character', 'Sprite').only('Invisible', 'MarkedForRemoval'); 187 | ``` 188 | 189 | ```js 190 | const query = world.createQuery({ 191 | all: ['Character', Sprite], 192 | only: ['Invisible', 'MarkedForRemoval'] 193 | }); 194 | ``` 195 | 196 | ## persist 197 | 198 | Indicate that the Query should be persisted, turning it into a live index of Entities. 199 | 200 | **Arguments**: 201 | * trackAdded: `Boolean`, _optional_, flag to track new Entity results from the Query since the last `system.update` 202 | * trackRemoved: `Boolean`, _optional_, flag to track removed Entity results from the Query since the last `system.update` 203 | 204 | The properties `query.added` and `query.removed` are `Sets` that you can check during your `system.update` if tracked. 205 | 206 | 👆 A query can be persisted without having to track added or removed. 207 | Whenever an Entity changes Component or Tag composition, it's checked against all persisted Queries when the `world.tick()` or after a `system.update(tick)` happens. 208 | 209 | 👆 Peristed queries only update their results after `system.update` or during `world.tick()`. 210 | If you want to update your persisted queries at other times, run [world.updateIndexes()](./World.md#updateindexes). 211 | 212 | 213 | ⚠ Queries cannot be persisted if they use `from` a static set of Entities, or if they're not created from a System. 214 | 215 | 💭 If you persist a LOT of Queries, it can have a performance from creating Entities, or adding/removing Components or Tags. 216 | 217 | ## refresh 218 | 219 | [execute](#execute) will not update results for changed entities, and persisted queries won't update within a single system update. To get new results in these situations, use refresh. 220 | 221 | ```js 222 | const world.registerTags('A', 'B'); 223 | const entity1 = world.createEntity({ tags: ['A'] }); 224 | const query = world.createQuery().fromAll('A', 'B'); 225 | const results1 = query.execute(); // doesn't include entity1 226 | entity1.addTag('B'); 227 | const results2 = query.execute(); // doesn't include entity1 228 | const result3 = query.refresh().execute(); //does include entity1 229 | ``` 230 | 231 | 👆 After each system run and each tick, [world.updateIndexes()](./World.md#updateindexes) which will refresh persisted queries from changed entities. 232 | You can run this directly as well. 233 | 234 | ## execute 235 | 236 | Execute a Query, returning all of the resulting Entities. 237 | 238 | **Arguments**: 239 | * filter: `Object`, _optional_, Filter Entities to results that had Component/tag composition changes since `updatedComponents` or Component value changes since `updatedValues`. 240 | 241 | ```js 242 | const query = world.createQuery().fromAll('Character', 'Sprite'); 243 | //only include entities that have been updated last tick or this tick 244 | const entities = query.execute({ 245 | updatedComponents: world.currentTick - 1, 246 | updatedValues: world.currentTick - 1 247 | }) 248 | ``` 249 | 250 | ⚠ If you neglect to call [component.update()](./Component.md#update) when you update the values of a Component, then `component.updated` and `entity.updatedValues` will not be updated, and the query filter for `updatedValues` will not be accurate. 251 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | // component changes that are passed to Systems 2 | export interface IComponentChange { 3 | op: string; 4 | props?: string[]; 5 | component: string; 6 | entity: string; 7 | type: string; 8 | target?: string; 9 | } 10 | 11 | // used by .fromReverse() in queries 12 | export interface IQueryReverse { 13 | entity: Entity | string; 14 | type: string | ComponentClass; 15 | } 16 | 17 | // the object passed to world.createQuery() 18 | export interface IQueryConfig { 19 | trackAdded?: boolean; 20 | trackRemoved?: boolean; 21 | includeApeDestroy?: boolean; 22 | persist?: boolean; 23 | from?: (Entity | string)[]; 24 | all?: (string | ComponentClass)[]; 25 | any?: (string | ComponentClass)[]; 26 | reverse?: IQueryReverse; 27 | not?: (string | ComponentClass)[]; 28 | only?: (string | ComponentClass)[]; 29 | } 30 | 31 | export declare class System { 32 | constructor(world: World, ...initArgs: any[]); 33 | world: World; 34 | changes: IComponentChange[]; 35 | queries: Query[]; 36 | lastTick: number; 37 | static subscriptions: string[]; 38 | init(...initArgs: any[]): void; 39 | update(tick: number): void; 40 | createQuery(init?: IQueryConfig): Query; 41 | subscribe(type: string | ComponentClass): void; 42 | } 43 | 44 | // passed to query.execute() 45 | export interface IQueryExecuteConfig { 46 | updatedComponents?: number; 47 | updatedValues?: number; 48 | } 49 | 50 | // returned from component.getObject() 51 | export interface IComponentObject { 52 | id?: string; 53 | entity?: string; 54 | [others: string]: any; 55 | } 56 | 57 | // used when creating an entity with the .c option 58 | export interface IComponentConfigVal { 59 | // type: string; 60 | id?: string; 61 | entity?: string; 62 | [others: string]: any; 63 | } 64 | 65 | export declare class Query { 66 | constructor(world: World, system: System, init: IQueryConfig); 67 | persisted: boolean; 68 | results: Set; 69 | executed: boolean; 70 | added: Set; 71 | removed: Set; 72 | trackAdded: boolean; 73 | trackRemoved: boolean; 74 | from(...entities: (Entity | string)[]): Query; 75 | fromReverse( 76 | entity: Entity | string, 77 | componentName: string | T 78 | ): Query; 79 | fromAll(...types: (string | (new () => Component))[]): Query; 80 | fromAny(...types: (string | (new () => Component))[]): Query; 81 | not(...types: (string | (new () => Component))[]): Query; 82 | only(...types: (string | (new () => Component))[]): Query; 83 | persist(trackAdded?: boolean, trackRemoved?: boolean): Query; 84 | refresh(): Query; 85 | execute(filter?: IQueryExecuteConfig): Set; 86 | } 87 | 88 | export interface IComponentUpdate { 89 | type?: never; 90 | [others: string]: any; 91 | } 92 | 93 | // in order to reference the class rather than the instance 94 | interface ComponentClass { 95 | new (): Component; 96 | } 97 | 98 | export declare class Component { 99 | preInit(initial: any): any; 100 | init(initial: any): void; 101 | get type(): string; 102 | set key(arg: string); 103 | get key(): string; 104 | destroy(): void; 105 | preDestroy(): void; 106 | postDestroy(): void; 107 | getObject(withIds?: boolean): IComponentObject; 108 | entity: Entity; 109 | id: string; 110 | update(values?: IComponentUpdate): void; 111 | [name: string]: any; 112 | static properties: Object; 113 | static serialize: Boolean; 114 | static serializeFields: string[]; 115 | static skipSerializeFields: string[]; 116 | static subbed: Boolean; 117 | static registered: Boolean; 118 | static typeName?: string; 119 | } 120 | 121 | // an object that has strings as keys and strings as values 122 | // has "Map" in the name because it's almost a Map(), close enough 123 | export interface IStringMap { 124 | [name: string]: string; 125 | } 126 | 127 | // an object that has strings as keys and strings or null as values 128 | export interface IStringNullMap { 129 | [name: string]: string | null; 130 | } 131 | 132 | // an object where the key is a string and the val is a set of Components 133 | export interface IEntityByType { 134 | [name: string]: Set; 135 | } 136 | 137 | // an object where the key is a string and the val is a single Component 138 | export interface IEntityComponents { 139 | [name: string]: Component; 140 | } 141 | 142 | // an object where the key is a string and the val is a single ComponentObject 143 | export interface IEntityComponentObjects { 144 | [name: string]: IComponentObject; 145 | } 146 | 147 | // Illegal properties without key or type or constructor 148 | export interface MostIllegalProperties { 149 | // constructor?: never; 150 | init?: never; 151 | // type?: never; 152 | // key?: never; 153 | destroy?: never; 154 | preDestroy?: never; 155 | postDestroy?: never; 156 | getObject?: never; 157 | _setup?: never; 158 | _reset?: never; 159 | update?: never; 160 | clone?: never; 161 | _meta?: never; 162 | _addRef?: never; 163 | _deleteRef?: never; 164 | prototyp?: never; 165 | } 166 | 167 | // passed to entity.addComponent() 168 | export interface IComponentConfig extends MostIllegalProperties { 169 | type: string; 170 | key?: string; 171 | [others: string]: any; 172 | } 173 | 174 | // an object where keys are strings and val is a IComponentConfigVal 175 | export interface IComponentConfigValObject { 176 | [name: string]: IComponentConfigVal; 177 | } 178 | 179 | // returned from entity.getObject() 180 | export interface IEntityObject { 181 | id: string; 182 | tags: string[]; 183 | components: IComponentObject[]; 184 | c: IEntityComponentObjects; 185 | } 186 | 187 | // an object where the key is a string and the val is a single System 188 | // export interface IWorldSubscriptions { 189 | // [name: string]: System; 190 | // } 191 | 192 | export declare class Entity { 193 | types: IEntityByType; 194 | c: IEntityComponents; 195 | id: string; 196 | tags: Set; 197 | updatedComponents: number; 198 | updatedValues: number; 199 | destroyed: boolean; 200 | // _setup(definition: any): void; 201 | has(type: string | ComponentClass): boolean; 202 | getOne(type: string): Component | undefined; 203 | getOne(type: { new (): T }): T | undefined; 204 | getComponents(type: string): Set; 205 | getComponents(type: { new (): T }): Set; 206 | addTag(tag: string): void; 207 | removeTag(tag: string): void; 208 | addComponent( 209 | properties: IComponentConfig | IComponentObject 210 | ): Component | undefined; 211 | removeComponent(component: Component | string): boolean; 212 | getObject(componentIds?: boolean): IEntityObject; 213 | destroy(): void; 214 | } 215 | 216 | export interface IWorldConfig { 217 | trackChanges?: boolean; 218 | entityPool?: number; 219 | cleanupPools?: boolean; 220 | useApeDestroy?: boolean; 221 | } 222 | 223 | // passed to world.createEntity() 224 | export interface IEntityConfig { 225 | id?: string; 226 | tags?: string[]; 227 | components?: IComponentConfig[]; 228 | c?: IComponentConfigValObject; 229 | } 230 | 231 | export interface IPoolStat { 232 | active: number; 233 | pooled: number; 234 | target: number; 235 | } 236 | 237 | export interface IWorldStats { 238 | entity: IPoolStat; 239 | components: { 240 | [key: string]: IPoolStat; 241 | }; 242 | } 243 | 244 | export declare class World { 245 | constructor(config?: IWorldConfig); 246 | currentTick: number; 247 | entities: Map; 248 | tags: Set; 249 | entitiesByComponent: IEntityByType; 250 | componentsById: Map; 251 | updatedEntities: Set; 252 | componentTypes: IEntityComponents; 253 | queries: Query[]; 254 | subscriptions: Map; 255 | systems: Map>; 256 | tick(): number; 257 | registerTags(...tags: string[]): void; 258 | 259 | // Both options allow the passing of a class that extends Component 260 | registerComponent( 261 | klass: T, 262 | spinup?: number 263 | ): void; 264 | 265 | getStats(): IWorldStats; 266 | logStats(freq: number, callback?: Function): void; 267 | 268 | createEntity(definition: IEntityConfig | IEntityObject): Entity; 269 | getObject(): IEntityObject[]; 270 | createEntities(definition: IEntityConfig[] | IEntityObject[]): void; 271 | copyTypes(world: World, types: string[]): void; 272 | removeEntity(id: Entity | string): void; 273 | getEntity(entityId: string): Entity | undefined; 274 | getEntities(type: string | ComponentClass): Set; 275 | getComponent(id: string): Component; 276 | createQuery(init?: IQueryConfig): Query; 277 | 278 | // Allows passing of a class that extends System, or an instance of such a class 279 | registerSystem( 280 | group: string, 281 | system: T | System, 282 | initParams?: any[] 283 | ): any; 284 | 285 | runSystems(group: string): void; 286 | updateIndexes(): void; 287 | } 288 | 289 | declare class EntitySetC extends Set { 290 | constructor(component: Component, object: any, field: string); 291 | component: Component; 292 | field: string; 293 | sub: string; 294 | dvalue: any; 295 | getValue(): string[]; 296 | } 297 | 298 | // This is a proxy 299 | export interface IEntityRef { 300 | get(): Entity; 301 | set(value: Entity | string): void; 302 | } 303 | 304 | // This is a proxy 305 | export interface IEntityObject { 306 | get(obj: IStringNullMap, prop: string): Entity; 307 | set(obj: IStringNullMap, prop: string, value: string): boolean; 308 | deleteProperty(obj: IStringNullMap, prop: string): boolean; 309 | [others: string]: any; 310 | } 311 | 312 | export function EntityRef( 313 | comp: Component, 314 | dvalue: any, 315 | field: string 316 | ): IEntityRef; 317 | export function EntityObject( 318 | comp: Component, 319 | object: any, 320 | field: string 321 | ): IEntityObject; 322 | export function EntitySet( 323 | component: Component, 324 | object: any[], 325 | field: string 326 | ): EntitySetC; 327 | -------------------------------------------------------------------------------- /src/query.js: -------------------------------------------------------------------------------- 1 | const Entity = require('./entity'); 2 | const Util = require('./util'); 3 | 4 | class Query { 5 | constructor(world, system, init) { 6 | this.system = system; 7 | this.world = world; 8 | this.query = { 9 | froms: [], 10 | filters: [] 11 | }; 12 | 13 | this.hasStatic = false; 14 | this.persisted = false; 15 | this.results = new Set(); 16 | this.executed = false; 17 | this.added = new Set(); 18 | this.removed = new Set(); 19 | 20 | if (this.world.config.useApeDestroy && !init) { 21 | this.not('ApeDestroy'); 22 | } 23 | 24 | if (init) { 25 | this.trackAdded = init.trackAdded || false; 26 | this.trackRemoved = init.trackRemoved || false; 27 | // istanbul ignore if 28 | if ((this.trackAdded || this.trackRemoved) && !this.system) { 29 | throw new Error( 30 | 'Queries cannot track added or removed when initialized outside of a system' 31 | ); 32 | } 33 | if (this.world.config.useApeDestroy && !init.includeApeDestroy) { 34 | if (init.not) { 35 | init.not.push('ApeDestroy'); 36 | } else { 37 | init.not = ['ApeDestroy']; 38 | } 39 | } 40 | if (init.from) { 41 | this.from(...init.from); 42 | } 43 | if (init.reverse) { 44 | this.fromReverse(init.reverse.entity, init.reverse.type); 45 | } 46 | if (init.all) { 47 | this.fromAll(...init.all); 48 | } 49 | if (init.any) { 50 | this.fromAny(...init.any); 51 | } 52 | if (init.not) { 53 | this.not(...init.not); 54 | } 55 | if (init.only) { 56 | this.only(...init.only); 57 | } 58 | if (init.persist) { 59 | this.persist(); 60 | } 61 | } 62 | } 63 | 64 | from(...entities) { 65 | entities = entities.map((e) => (typeof e !== 'string' ? e.id : e)); 66 | this.query.froms.push({ 67 | from: 'from', 68 | entities 69 | }); 70 | this.hasStatic = true; 71 | return this; 72 | } 73 | 74 | fromReverse(entity, componentName) { 75 | if (typeof entity === 'string') { 76 | entity = this.world.getEntity(entity); 77 | } 78 | if (typeof componentName === 'function') { 79 | componentName = componentName.name; 80 | } 81 | this.query.froms.push({ 82 | from: 'reverse', 83 | entity, 84 | type: componentName 85 | }); 86 | return this; 87 | } 88 | 89 | fromAll(...types) { 90 | const stringTypes = types.map((t) => (typeof t !== 'string' ? t.name : t)); 91 | this.query.froms.push({ 92 | from: 'all', 93 | types: stringTypes 94 | }); 95 | return this; 96 | } 97 | 98 | fromAny(...types) { 99 | const stringTypes = types.map((t) => (typeof t !== 'string' ? t.name : t)); 100 | this.query.froms.push({ 101 | from: 'any', 102 | types: stringTypes 103 | }); 104 | return this; 105 | } 106 | 107 | not(...types) { 108 | const stringTypes = types.map((t) => (typeof t !== 'string' ? t.name : t)); 109 | this.query.filters.push({ 110 | filter: 'not', 111 | types: stringTypes 112 | }); 113 | return this; 114 | } 115 | 116 | only(...types) { 117 | const stringTypes = types.map((t) => (typeof t !== 'string' ? t.name : t)); 118 | this.query.filters.push({ 119 | filter: 'only', 120 | types: stringTypes 121 | }); 122 | return this; 123 | } 124 | 125 | update(entity) { 126 | let inFrom = false; 127 | for (const source of this.query.froms) { 128 | if (source.from === 'all') { 129 | let found = true; 130 | for (const type of source.types) { 131 | if (!entity.has(type)) { 132 | found = false; 133 | break; 134 | } 135 | } 136 | if (found) { 137 | inFrom = true; 138 | break; 139 | } 140 | } else if (source.from === 'any') { 141 | const potential = []; 142 | let found = false; 143 | for (const type of source.types) { 144 | if (entity.has(type)) { 145 | found = true; 146 | break; 147 | } 148 | } 149 | if (found) { 150 | inFrom = true; 151 | break; 152 | } 153 | } /* istanbul ignore else */ else if (source.from === 'reverse') { 154 | // istanbul ignore else 155 | if ( 156 | this.world.entityReverse.hasOwnProperty(source.entity.id) && 157 | this.world.entityReverse[source.entity.id].hasOwnProperty(source.type) 158 | ) { 159 | const keys = new Set( 160 | this.world.entityReverse[source.entity.id][source.type].keys() 161 | ); 162 | if ( 163 | new Set( 164 | this.world.entityReverse[source.entity.id][source.type].keys() 165 | ).has(entity.id) 166 | ) { 167 | inFrom = true; 168 | break; 169 | } 170 | } 171 | } 172 | } 173 | if (inFrom) { 174 | this.results.add(entity); 175 | this._filter(entity); 176 | if (this.trackAdded) { 177 | this.added.add(entity); 178 | } 179 | } else { 180 | this._removeEntity(entity); 181 | } 182 | } 183 | 184 | _removeEntity(entity) { 185 | if (this.results.has(entity) && this.trackRemoved) { 186 | this.removed.add(entity); 187 | } 188 | this.results.delete(entity); 189 | } 190 | 191 | persist(trackAdded, trackRemoved) { 192 | // istanbul ignore if 193 | if (this.hasStatic) { 194 | throw new Error('Cannot persist query with static list of entities.'); 195 | } 196 | // istanbul ignore if 197 | if (this.query.froms.length === 0) { 198 | throw new Error( 199 | 'Cannot persist query without entity source (fromAll, fromAny, fromReverse).' 200 | ); 201 | } 202 | 203 | this.world.queries.push(this); 204 | if (this.system !== null) { 205 | this.system.queries.push(this); 206 | } 207 | 208 | if (typeof trackAdded === 'boolean') { 209 | this.trackAdded = trackAdded; 210 | } 211 | if (typeof trackRemoved === 'boolean') { 212 | this.trackRemoved = trackRemoved; 213 | } 214 | this.persisted = true; 215 | return this; 216 | } 217 | 218 | clearChanges() { 219 | this.added.clear(); 220 | this.removed.clear(); 221 | } 222 | 223 | refresh() { 224 | //load in entities using from methods 225 | let results = new Set(); 226 | for (const source of this.query.froms) { 227 | // instanbul ignore else 228 | if (source.from === 'from') { 229 | results = Util.setUnion(results, source.entities); 230 | } else if (source.from === 'all') { 231 | if (source.types.length === 1) { 232 | // istanbul ignore if 233 | if (!this.world.entitiesByComponent.hasOwnProperty(source.types[0])) { 234 | throw new Error( 235 | `${source.types[0]} is not a registered Component/Tag` 236 | ); 237 | } 238 | results = Util.setUnion( 239 | results, 240 | this.world.entitiesByComponent[source.types[0]] 241 | ); 242 | } else { 243 | const comps = []; 244 | for (const type of source.types) { 245 | const entities = this.world.entitiesByComponent[type]; 246 | // istanbul ignore if 247 | if (entities === undefined) { 248 | throw new Error(`${type} is not a registered Component/Tag`); 249 | } 250 | comps.push(entities); 251 | } 252 | results = Util.setUnion(results, Util.setIntersection(...comps)); 253 | } 254 | } else if (source.from === 'any') { 255 | const comps = []; 256 | for (const type of source.types) { 257 | const entities = this.world.entitiesByComponent[type]; 258 | // istanbul ignore if 259 | if (entities === undefined) { 260 | throw new Error(`${type} is not a registered Component/Tag`); 261 | } 262 | comps.push(entities); 263 | } 264 | results = Util.setUnion(results, ...comps); 265 | } /* istanbul ignore else */ else if (source.from === 'reverse') { 266 | // istanbul ignore else 267 | if ( 268 | this.world.entityReverse[source.entity.id] && 269 | this.world.entityReverse[source.entity.id].hasOwnProperty(source.type) 270 | ) { 271 | results = Util.setUnion( 272 | results, 273 | new Set([ 274 | ...this.world.entityReverse[source.entity.id][source.type].keys() 275 | ]) 276 | ); 277 | } 278 | } 279 | } 280 | 281 | this.results = new Set( 282 | [...results] 283 | .map((id) => this.world.getEntity(id)) 284 | .filter((entity) => !!entity) 285 | ); 286 | 287 | //filter results 288 | for (const entity of this.results) { 289 | this._filter(entity); 290 | } 291 | 292 | if (this.trackAdded) { 293 | this.added = new Set(this.results); 294 | } 295 | 296 | return this; 297 | } 298 | 299 | _filter(entity) { 300 | for (const filter of this.query.filters) { 301 | if (filter.filter === 'not') { 302 | for (const type of filter.types) { 303 | if (entity.has(type)) { 304 | this.results.delete(entity); 305 | break; 306 | } 307 | } 308 | } /* istanbul ignore else */ else if (filter.filter === 'only') { 309 | let found = false; 310 | for (const type of filter.types) { 311 | if (entity.has(type)) { 312 | found = true; 313 | break; 314 | } 315 | } 316 | if (!found) { 317 | this.results.delete(entity); 318 | } 319 | } 320 | } 321 | } 322 | 323 | execute(filter) { 324 | if (!this.executed) { 325 | this.refresh(); 326 | } 327 | this.executed = true; 328 | // istanbul ignore next 329 | if ( 330 | filter === undefined || 331 | (!filter.hasOwnProperty('updatedComponents') && 332 | !filter.hasOwnProperty('updatedValues')) 333 | ) { 334 | return this.results; 335 | } 336 | const output = []; 337 | for (const entity of this.results) { 338 | // istanbul ignore next 339 | if ( 340 | !( 341 | filter.updatedComponents && 342 | entity.updatedComponents < filter.updatedComponents 343 | ) && 344 | !(filter.updatedValues && entity.updatedValues < filter.updatedValues) 345 | ) { 346 | output.push(entity); 347 | } 348 | } 349 | return new Set(output); 350 | } 351 | } 352 | 353 | module.exports = Query; 354 | -------------------------------------------------------------------------------- /src/world.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @module ecs/ECS 3 | * @type {class} 4 | */ 5 | const Entity = require('./entity'); 6 | const Query = require('./query'); 7 | const ComponentPool = require('./componentpool'); 8 | const EntityPool = require('./entitypool'); 9 | const setupApeDestroy = require('./cleanup'); 10 | 11 | const componentReserved = new Set([ 12 | 'constructor', 13 | 'init', 14 | 'type', 15 | 'key', 16 | 'destroy', 17 | 'preDestroy', 18 | 'postDestroy', 19 | 'getObject', 20 | '_setup', 21 | '_reset', 22 | 'update', 23 | 'clone', 24 | '_meta', 25 | '_addRef', 26 | '_deleteRef', 27 | 'prototype' 28 | ]); 29 | 30 | /** 31 | * Main library class for registering Components, Systems, Queries, 32 | * and runnning Systems. 33 | * Create multiple World instances in order to have multiple collections. 34 | * @exports World 35 | */ 36 | module.exports = class World { 37 | constructor(config) { 38 | this.config = Object.assign( 39 | { 40 | trackChanges: true, 41 | entityPool: 10, 42 | cleanupPools: true, 43 | useApeDestroy: false 44 | }, 45 | config 46 | ); 47 | this.currentTick = 0; 48 | this.entities = new Map(); 49 | this.types = {}; 50 | this.tags = new Set(); 51 | this.entitiesByComponent = {}; 52 | this.componentsById = new Map(); 53 | this.entityReverse = {}; 54 | this.updatedEntities = new Set(); 55 | this.componentTypes = {}; 56 | this.components = new Map(); 57 | this.queries = []; 58 | this.subscriptions = new Map(); 59 | this.systems = new Map(); 60 | this.refs = {}; 61 | this.componentPool = new Map(); 62 | this._statCallback = null; 63 | this._statTicks = 0; 64 | this._nextStat = 0; 65 | this.entityPool = new EntityPool(this, this.config.entityPool); 66 | if (this.config.useApeDestroy) { 67 | setupApeDestroy(this); 68 | } 69 | } 70 | 71 | /** 72 | * Called in order to increment ecs.currentTick, update indexed queries, and update key. 73 | * @method module:ECS#tick 74 | */ 75 | tick() { 76 | if (this.config.useApeDestroy) { 77 | this.runSystems('ApeCleanup'); 78 | } 79 | this.currentTick++; 80 | this.updateIndexes(); 81 | this.entityPool.release(); 82 | // istanbul ignore else 83 | if (this.config.cleanupPools) { 84 | this.entityPool.cleanup(); 85 | for (const [key, pool] of this.componentPool) { 86 | pool.cleanup(); 87 | } 88 | } 89 | if (this._statCallback) { 90 | this._nextStat += 1; 91 | if (this._nextStat >= this._statTicks) { 92 | this._outputStats(); 93 | } 94 | } 95 | return this.currentTick; 96 | } 97 | 98 | getStats() { 99 | const stats = { 100 | entity: { 101 | active: this.entities.size, 102 | pooled: this.entityPool.pool.length, 103 | target: this.entityPool.targetSize 104 | }, 105 | components: {} 106 | }; 107 | for (const [key, pool] of this.componentPool) { 108 | stats.components[key] = { 109 | active: pool.active, 110 | pooled: pool.pool.length, 111 | target: pool.targetSize 112 | }; 113 | } 114 | return stats; 115 | } 116 | 117 | logStats(freq, callback) { 118 | // istanbul ignore next 119 | if (callback === undefined) { 120 | callback = console.log; 121 | } 122 | this._statCallback = callback; 123 | this._statTicks = freq; 124 | this._nextStat = 0; 125 | } 126 | 127 | _outputStats() { 128 | const stats = this.getStats(); 129 | this._nextStat = 0; 130 | let output = `${this.currentTick}, Entities: ${stats.entity.active} active, ${stats.entity.pooled}/${stats.entity.target} pooled`; 131 | for (const key of Object.keys(stats.components)) { 132 | const cstat = stats.components[key]; 133 | output += `\n${this.currentTick}, ${key}: ${cstat.active} active, ${cstat.pooled}/${cstat.target} pooled`; 134 | } 135 | this._statCallback(output); 136 | } 137 | 138 | _addRef(target, entity, component, prop, sub, key, type) { 139 | if (!this.refs[target]) { 140 | this.refs[target] = new Set(); 141 | } 142 | const eInst = this.getEntity(target); 143 | if (!this.entityReverse.hasOwnProperty(target)) { 144 | this.entityReverse[target] = {}; 145 | } 146 | if (!this.entityReverse[target].hasOwnProperty(key)) { 147 | this.entityReverse[target][key] = new Map(); 148 | } 149 | const reverse = this.entityReverse[target][key]; 150 | let count = reverse.get(entity); 151 | /* $lab:coverage:off$ */ 152 | if (count === undefined) { 153 | count = 0; 154 | } 155 | /* $lab:coverage:on$ */ 156 | reverse.set(entity, count + 1); 157 | this.refs[target].add([entity, component, prop, sub].join('...')); 158 | this._sendChange({ 159 | op: 'addRef', 160 | component: component, 161 | type: type, 162 | property: prop, 163 | target, 164 | entity 165 | }); 166 | } 167 | 168 | _deleteRef(target, entity, component, prop, sub, key, type) { 169 | const ref = this.entityReverse[target][key]; 170 | let count = ref.get(entity); 171 | count--; 172 | // istanbul ignore else 173 | if (count < 1) { 174 | ref.delete(entity); 175 | } else { 176 | ref.set(entity, count); 177 | } 178 | if (ref.size === 0) { 179 | delete ref[key]; 180 | } 181 | this.refs[target].delete([entity, component, prop, sub].join('...')); 182 | if (this.refs[target].size === 0) { 183 | delete this.refs[target]; 184 | } 185 | this._sendChange({ 186 | op: 'deleteRef', 187 | component, 188 | type: type, 189 | target, 190 | entity, 191 | property: prop 192 | }); 193 | } 194 | 195 | /** 196 | * @typedef {Object} definition 197 | * @property {Object} properites 198 | * @property {function} init 199 | */ 200 | 201 | /** 202 | * If you're going to use tags, you needs to let the ECS instance know. 203 | * @method module:ECS#registerTags 204 | * @param {string[]|string} tags - Array of tags to register, or a single tag. 205 | * @example 206 | * ecs.registerTags['Item', 'Blocked']); 207 | */ 208 | registerTags(...tags) { 209 | for (const tag of tags) { 210 | // istanbul ignore if 211 | if (this.entitiesByComponent.hasOwnProperty(tag)) { 212 | throw new Error(`Cannot register tag "${tag}", name is already taken.`); 213 | } 214 | this.entitiesByComponent[tag] = new Set(); 215 | this.tags.add(tag); 216 | } 217 | } 218 | 219 | registerComponent(klass, spinup = 1) { 220 | if (klass.typeName && klass.name !== klass.typeName) { 221 | Object.defineProperty(klass, 'name', { value: klass.typeName }); 222 | } 223 | const name = klass.name; 224 | // istanbul ignore if 225 | if (this.tags.has(name)) { 226 | throw new Error(`registerComponent: Tag already defined for "${name}"`); 227 | } /* istanbul ignore if */ else if ( 228 | this.componentTypes.hasOwnProperty(name) 229 | ) { 230 | throw new Error( 231 | `registerComponent: Component already defined for "${name}"` 232 | ); 233 | } 234 | this.componentTypes[name] = klass; 235 | if (!klass.registered) { 236 | klass.registered = true; 237 | klass.fields = Object.keys(klass.properties); 238 | klass.primitives = {}; 239 | klass.factories = {}; 240 | for (const field of klass.fields) { 241 | // istanbul ignore if 242 | if (componentReserved.has(field)) { 243 | throw new Error( 244 | `Error registering ${klass.name}: Reserved property name "${field}"` 245 | ); 246 | } 247 | if (typeof klass.properties[field] === 'function') { 248 | klass.factories[field] = klass.properties[field]; 249 | } else { 250 | klass.primitives[field] = klass.properties[field]; 251 | } 252 | } 253 | } 254 | this.entitiesByComponent[name] = new Set(); 255 | this.componentPool.set(name, new ComponentPool(this, name, spinup)); 256 | } 257 | 258 | createEntity(definition) { 259 | return this.entityPool.get(definition); 260 | } 261 | 262 | getObject() { 263 | const obj = []; 264 | for (const kv of this.entities) { 265 | obj.push(kv[1].getObject()); 266 | } 267 | return obj; 268 | } 269 | 270 | createEntities(definition) { 271 | for (const entityDef of definition) { 272 | this.createEntity(entityDef); 273 | } 274 | } 275 | 276 | copyTypes(world, types) { 277 | for (const name of types) { 278 | if (world.tags.has(name)) { 279 | this.registerTags(name); 280 | } else { 281 | const klass = world.componentTypes[name]; 282 | this.componentTypes[name] = klass; 283 | this.entitiesByComponent[name] = new Set(); 284 | this.componentPool.set(name, new ComponentPool(this, name, 1)); 285 | } 286 | } 287 | } 288 | 289 | removeEntity(id) { 290 | let entity; 291 | if (id instanceof Entity) { 292 | entity = id; 293 | id = entity.id; 294 | } else { 295 | entity = this.getEntity(id); 296 | } 297 | entity.destroy(); 298 | } 299 | 300 | getEntity(entityId) { 301 | return this.entities.get(entityId); 302 | } 303 | 304 | getEntities(type) { 305 | if (typeof type !== 'string') { 306 | type = type.name; 307 | } 308 | const results = [...this.entitiesByComponent[type]]; 309 | return new Set(results.map((id) => this.getEntity(id))); 310 | } 311 | 312 | getComponent(id) { 313 | return this.componentsById.get(id); 314 | } 315 | 316 | createQuery(init) { 317 | return new Query(this, null, init); 318 | } 319 | 320 | _sendChange(operation) { 321 | if (this.componentTypes[operation.type].subbed) { 322 | const systems = this.subscriptions.get(operation.type); 323 | // istanbul ignore if 324 | if (!systems) { 325 | return; 326 | } 327 | for (const system of systems) { 328 | system._recvChange(operation); 329 | } 330 | } 331 | } 332 | 333 | registerSystem(group, system, initParams) { 334 | initParams = initParams || []; 335 | if (typeof system === 'function') { 336 | system = new system(this, ...initParams); 337 | } 338 | if (!this.systems.has(group)) { 339 | this.systems.set(group, new Set()); 340 | } 341 | this.systems.get(group).add(system); 342 | return system; 343 | } 344 | 345 | runSystems(group) { 346 | const systems = this.systems.get(group); 347 | if (!systems) return; 348 | for (const system of systems) { 349 | system._preUpdate(); 350 | system.update(this.currentTick); 351 | system._postUpdate(); 352 | system.lastTick = this.currentTick; 353 | if (system.changes.length !== 0) { 354 | system.changes = []; 355 | } 356 | } 357 | } 358 | 359 | _entityUpdated(entity) { 360 | // istanbul ignore else 361 | if (this.config.trackChanges) { 362 | this.updatedEntities.add(entity); 363 | } 364 | } 365 | 366 | _addEntityComponent(name, entity) { 367 | this.entitiesByComponent[name].add(entity.id); 368 | } 369 | 370 | _deleteEntityComponent(component) { 371 | this.entitiesByComponent[component.type].delete(component._meta.entityId); 372 | } 373 | 374 | _clearIndexes(entity) { 375 | for (const query of this.queries) { 376 | query._removeEntity(entity); 377 | } 378 | this.updatedEntities.delete(entity); 379 | } 380 | 381 | updateIndexes() { 382 | for (const entity of this.updatedEntities) { 383 | this._updateIndexesEntity(entity); 384 | } 385 | this.updatedEntities.clear(); 386 | } 387 | 388 | _updateIndexesEntity(entity) { 389 | for (const query of this.queries) { 390 | query.update(entity); 391 | } 392 | } 393 | }; 394 | -------------------------------------------------------------------------------- /docs/Component.md: -------------------------------------------------------------------------------- 1 | # Component 2 | 3 | Components are the datatypes we use in the Entity-Component-System paradigm (ECS). 4 | In **Ape ECS**, Components are single shallow Objects managed by JS classes. 5 | Those properties can be JavaScript types and special Entity Reference values. 6 | 7 | ```js 8 | class EquipmentSlot extends ApeECS.Component { 9 | 10 | init(values) { 11 | } 12 | 13 | preDestroy() { 14 | } 15 | } 16 | 17 | // we could assign parameters and other static properties 18 | // directly onto EquipmentSlot and it would be equivalant 19 | EquipmentSlot.parameters = { 20 | name: 'Right Hand', 21 | slotType: 'wieldable', 22 | slot: ApeECS.EntityRef, 23 | holding: false 24 | }; 25 | 26 | //default 27 | EquipmentSlot.serialize = true; 28 | // defaults to null, only serialize fields listed 29 | EquipmentSlot.serializeFields = ['name', 'slotType', 'slot']; 30 | // defaults to null, don't serialize these fieldsk 31 | EquipmentSlot.skipSerializeFields = ['holding']; 32 | // defaults to false, when true it sends change events 33 | // on property changes through component.update() 34 | EquipmentSlot.changeEvents = false; 35 | // set typeName if your build system renames functions 36 | EquipmentSlot.typeName = 'EquipmentSlot'; 37 | ``` 38 | 39 | 👆 You could use `static` parameters instead of assigning these values to the class, but as of this writing is still a stage-3 proposal for ECMAScript. 40 | 41 | 👀 See the [World registerComponent documentation](./World.md#registercomponent) for information on how to define a new Component type. 42 | 43 | 👀 See the [Entity Ref Docs](./Entity_Refs.md) for more on Entity Reference properties. 44 | 45 | Components can only exist when they're part of an Entity. 46 | You can have any number of the same Component type within an Entity. 47 | You create instances of a Component either by defining them within [world.createEnitity](./World.md#createentity), [world.createEntities](./World.md#createentities), or [entity.addComponent](./Entity.md#addcomponent). 48 | Component instances are destroyed through [component.destroy](#destroy) or [entity.removeComponent](./Entity.md#removecomponent). 49 | 50 | Component property values can be accessed and changed directly on the Component instance like any other object. 51 | 52 | ⚠️ You _should_ run [component.update()](#update) after you update properties on a `Component`, or update those properties by passing an object with your new values to `component.update()`. Currently, the only consequences for not calling this method are: 53 | * `component.updated` won't be updated to the current tick 54 | * `entity.updatedValues` won't be updated to the current tick 55 | * Query filters by `lastUpdated` won't be accurate. 56 | * Change events won't be produced. 57 | 58 | In future versions, there may be more features tied to this functionality. 59 | 60 | ### Index 61 | * [Creating Component Instances](#creating-component-instances) 62 | * [id](#id) 63 | * [properties](#properties) 64 | * [key](#key) 65 | * [init](#init) 66 | * [preInit](#preinit) 67 | * [update](#update) 68 | * [entity](#entity) 69 | * [getObject](#getobject) 70 | * [destroy](#destroy) 71 | * [Setters and Getters](#setters-and-getters) 72 | 73 | ## Creating Component Instances 74 | 75 | There are a few factory functions for creating Components. When you create `Entities` you can include the initial components and their values. 76 | 77 | * [world.createEntity](./World.md#createentity) 78 | * [world.createEntities](./World.md#createentities) 79 | 80 | Or you can add them on to an existing `Entity`. 81 | * [entity.addComponent](./Entity.md#addcomponent) 82 | 83 | Regardless of the method you use to create a `Component`, the format is as follows: 84 | 85 | ```js 86 | { 87 | type: 'Position', // Component Type 88 | id: 'hi-there', // optional, unique id, auto generated if not provided 89 | key: 'position', // optional, if set you can access the component by this value from the Entity instance 90 | // see below for more on keys 91 | x: 34, // set the initial value of any property defined when registered 92 | y: 3 // another property that you previously defined 93 | // you don't have to assign values to all of your properties 94 | } 95 | ``` 96 | 97 | 📑 Related, you can always add `Tags`, which are similar to Components, except they're just the name/type. [entity.addTag](./Entity.md#addtag) 98 | 99 | ☝️ `Components` can't exist without being part of an `Entity`. A future release may add more ways to use `Components.` 100 | 101 | 💭 `Components` are pooled by their type. When you [world.registerComponent](./World.md#registercomponent), set a pool size close to the number of a given type you expect to exist at a time. The `Component` pools add some efficiency, and lowers the amount of CPU time the JavaScript [garbage collector](https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)) needs. 102 | 103 | ## id 104 | 105 | The id property of a `Component` is a `String` that has generally been auto-generated. 106 | 107 | ⚠️ Do not reassign the id of an `Entity` or `Component` after they have been created. Here there be dragons. 🐉 108 | 109 | ## properties 110 | 111 | You can access any property directly on a `Component` instance that you have registered with the component. 112 | 113 | See the [example](#example) at the [top of this document](#component). 114 | 115 | ⚠️ If you assign any new properties to a `Component` that you didn't register, they won't behave properly. 116 | 117 | ⚠️ When you override a `Component` method, be wary of inserting game logic to Components as it quickly turns your game into an `EC` game rather than an ECS. You'll lose some of the benefits of this approach. You might also make logic that limits you in the future, like the ability to run the same Components on a server and a client. Keep your game logic in your `Systems` and override `Component` methods only for data formatting and access. 118 | 119 | For example, if you have a game engine sprite, you might have a Component that contains all of the necessary information to stand up a sprite, and a reference to the game engine sprite instance itself. When you store the sprite you may need to remove the sprite instance from the Component, because it can't be serialized. You can do that by overriding `getObject`. But resist the tempation to re-create the game engine sprite in `init`. That won't work on your server. Instead, tag any new Entitys that have an unititialized Sprite `Component` with "New" or "NewSprite". You can then have a `System` `Query` that checks for `Query.fromAll(['Sprite', 'New'])` to initialize it for you. 120 | 121 | ## key 122 | 123 | Setting the key property will map the `Component` within its `entity.c` by that value for convenience. As always, that same `Component` will still be accessible within `entity.types`. 124 | 125 | ```js 126 | const entity = world.createEntity({ 127 | components: [ 128 | { 129 | type: 'Position', 130 | key: 'position', 131 | x: 3, 132 | y: 10 133 | } 134 | ] 135 | }); 136 | 137 | console.log(entity.c.position.x); // 3 138 | 139 | for (const position of entity.types.Position) { 140 | console.log(position.x, position.y); 141 | } 142 | // 3 10 143 | ``` 144 | 145 | 👆 Setting the `key` property is completely optional. 146 | If a `Component` instance doesn't have a key, it doesn't show up in the `entity.c` Object, but it will still be retrievable with [entity.getComponents(type)](./Entity.md#getcomponents) and the `entity.types[type]`. Each return `Sets` of Components on the Entity of a given type. 147 | 148 | For example, an `Entity` representing a character may have many Component instances of the type "Buff", but only one "Inventory" Component. In that case, it doesn't make sense to have a `key` on any of the Buff instances, but it does on the "Inventory" component for convenience. 149 | 150 | 👆 You can also set the key in an `Entity` creation factory on the `c` property. 151 | 152 | This is equivalant to the above: 153 | ```js 154 | const entity = world.createEntity({ 155 | c: { 156 | position: { 157 | type: 'Position', 158 | x: 3, 159 | y: 10 160 | } 161 | } 162 | }); 163 | ``` 164 | 165 | ## init 166 | 167 | You can override the `init` method of your `Component`. 168 | It's ran after the `Component` has been created through any of the factory methods. 169 | 170 | **Arguments:** 171 | * initialValues: `Object` values that were passed as the initial property values. Does not include the results of any `setters` or defaults. 172 | 173 | ## preInit 174 | 175 | You can override the `preInit` method of your `Component`. 176 | It's ran before the `Component` has assigned the initial values. 177 | You must return an object of initial values to get assigned. 178 | 179 | **Arguments:** 180 | * initialValues: `Object` values that were passed as the initial property values. Does not include the results of any `setters` or defaults. 181 | 182 | **Returns** Initial values to assign. 183 | 184 | 👆 This is a good place to set up any properties on the `_meta` object that you may need for your getters/setters. 185 | 186 | ## update 187 | 188 | Method to mark Component as updated, optionally update properties, optionally send [system.subscribe()](./System.md#subscribe) change event. 189 | 190 | **Arguments:** 191 | * values: `Object`, _optional_, properties and values of component to update 192 | 193 | You can update the properties on a Component directly, but [query.execute()](./Query.md#execute) will not be able to filter by updatedValues if you don't. 194 | Additionally, if you [system.subscribe()](./System.md#subscribe) to a Component type, you won't get change events unless you use this method to update values. 195 | 196 | 👆 You'll also need to set the static property `changeEvents = true` if you want change subscription events. 197 | 198 | ## entity 199 | 200 | The entity property gives you access to the parent `Entity` instance. 201 | 202 | ## getObject 203 | 204 | Get a serializable version of the Component. 205 | 206 | ```js 207 | const obj = component.getObject(); 208 | console.log(obj); 209 | ``` 210 | ```js 211 | { 212 | type: 'Point', // component type name 213 | id: 'aabbbcc-37', // generated or assigned component id 214 | entityId: 'kjasdlf-03', // generated or assigned entity id 215 | key: 'point', // assigned key, if set 216 | x: 38, // any properties that you registered 217 | y: 44 218 | } 219 | ``` 220 | 221 | ☝️ If you have a staticarray for `serializeFields`, then only those fields will be in the resulting object. 222 | `skipSerializeFields` is the opposite approach, listing fields that you do not wish to serialize. 223 | 224 | ☝️ You can use the results of `component.getObject` as the component in [entity.addComponent](./Entity.md#addcomponent) and as a component part in [world.createEntity](./World.md#createentity) and [world.createEntities](./World.md#createentities). 225 | 226 | 💭 `getObject` will grab `meta.values` for a given property, if available. 227 | 228 | 💭 `component.getObject` is called by [entity.getObject](./Entity.md#getobject) and [world.getObject](./World.md#getobject) unless you specify the static property `serialize = false` on the Component class. 229 | 230 | ## destroy 231 | 232 | Destroy the component. 233 | 234 | ```js 235 | someComponent.destroy(); 236 | ``` 237 | 238 | Before any other actions are taken, the `preDestroy` function is run. 239 | 240 | 💭 This has the same effect as [entity.removeComponent](./Entity.md#removecomponent). This clears all the data in the component and releases it back to the `Component` pool of its type. 241 | 242 | ## Setters and Getters 243 | 244 | You can add setters and getters for any property that you defined. 245 | 246 | ```js 247 | class Position extends ApeECS.Component { 248 | 249 | get x() { 250 | return this._meta.values.x; 251 | } 252 | 253 | set x(value) { 254 | this._meta.values.x = value; 255 | this.coord = `${this.x}x${this.y}`; 256 | } 257 | 258 | get y() { 259 | return this._meta.values.y; 260 | } 261 | 262 | set y(value) { 263 | this._meta.values.y = value; 264 | this.coord = `${this.x}x${this.y}`; 265 | } 266 | } 267 | 268 | Position.properties = { 269 | x: 0, 270 | y: 0, 271 | coord: '0x0' 272 | }; 273 | ``` 274 | 275 | If you define a setter for a property, be sure to store it in the `_meta.values` `Object` since you won't be able to store it under the same name. 276 | 277 | `getObject` assumes that if a `_meta.values` property is set, that it's the serializable version. You may need to override `getObject` or store your value elsewhere in `_meta` if this assumption is incorrect. 278 | You can override [preInit](#preinit) if you want to setup a separate place in `_meta` for your values. 279 | 280 | ⭐️ Some of you may have noticed that we could have done the above example more efficiently. 281 | You get a gold star, but I needed a more complete example. 282 | Here's the simpler version: 283 | 284 | ```js 285 | class Position extends ApeECS.Component { 286 | 287 | get coord() { 288 | return `${this.x}x${this.y}`; 289 | } 290 | } 291 | 292 | Position.properties = { 293 | x: 0, 294 | y: 0, 295 | coord: '0x0' 296 | }; 297 | ``` 298 | -------------------------------------------------------------------------------- /docs/World.md: -------------------------------------------------------------------------------- 1 | # World 2 | 3 | The world is the main class for **Ape ECS** . From there you can work with `Components`, `Entities`, `Queries`, and `Systems`. Your application can have more than one world, but all of your `Entities`, `Components`, `Systems`, and `Queries` will be specific to a given `World` (although there are ways to clone things between worlds). 4 | 5 | An instance of `World` is essentially a registry of your game or simulation data, systems, and types. You've got to start with a world before you can do anything else. Typically you create your world, register your tags, components, and systems, and then start creating entities and running systems. 6 | 7 | ## Index 8 | * [World constructor](#world-constructor) 9 | * [currentTick](#currenttick) 10 | * [tick](#tick) 11 | * [registerComponent](#registercomponent) 12 | * [registerTags](#registertags) 13 | * [createEntity](#createentity) 14 | * [getObject](#getobject) 15 | * [createEntities](#createentities) 16 | * [copyTypes](#copytypes) 17 | * [getEntity](#getentity) 18 | * [removeEntity](#removeentity) 19 | * [getEntities](#getentities) 20 | * [getComponent](#getcomponent) 21 | * [createQuery](#createquery) 22 | * [registerSystem](#registersystem) 23 | * [runSystems](#runsystems) 24 | * [updateIndexes](#updateindexes) 25 | 26 | ## World constructor 27 | 28 | Constructs a new `World` instance. 29 | 30 | ```js 31 | const myWorld = new ApeECS.World({ 32 | trackChanges: true, 33 | entityPool: 10, 34 | cleanupPools: true, 35 | useApeDestroy: true 36 | }); 37 | ``` 38 | 39 | ### Arguments: 40 | * config: `Object`, optional 41 | - trackChanges: `bool`, default `true` 42 | - entityPool: `Number`, default `10` 43 | - cleanupPools: `bool`, default `true` 44 | - useApeDestroy: `bool`, default `false`: 45 | 46 | ### Notes: 47 | 48 | Turning off `trackChanges` removes the events that a `System` can subscribe to. 49 | 50 | The initial `entityPool` spins up a given number `Entity` instances for later use. Pooling configuration for `Component` instances is done by indivual types at registration. 51 | 52 | `cleanupPools` shrinks the pool back down to between the set pool size and double the the pool size. Without it, the pool can be as large as the max number of entities or a given component type that has ever existed. 53 | 54 | `useApeDestroy` adds the `ApeDestroy` tag and `ApeCleanup` system group, which then runs at each [tick()](#tick). 55 | If you want to destroy an Entity, but not until the end of the tick, just add the tag `ApeDestroy`. 56 | It keeps the entity around until the end, but it won't show up in [Query.execute()](./Query.md#execute) results unless you set `createQuery({ includeApeDestroy: true })`. 57 | `useApeDestroy` setup up a set of builtin behaviors for a common pattern. 58 | 59 | ### Example: 60 | ```js 61 | const ApeECS = require('ape-ecs'); 62 | const myworld = new ApeECS.World({ 63 | trackChanges: true, // default 64 | entityPool: 10 // default 65 | }); 66 | ``` 67 | 68 | ## currentTick 69 | 70 | The `currentTick` is a Number integer property incremented by the `world.tick()` method. It can be used to determine how recently `Entities` and `Components` have been updated based on their `Entity.updatedComponents` and `Component._meta.updated` values. 71 | 72 | ```js 73 | const q1 = world.createQuery().fromAll('Position', 'Tile'); 74 | const tiles = q1.execute({updatedComponents: world.currentTick - 1 }); 75 | ``` 76 | 77 | ## tick 78 | 79 | ```js 80 | world.tick(); 81 | ``` 82 | 83 | Increments the `world.currentTick` and manages any housekeeping that **Ape ECS** needs to do on the world between system runs. Once you've run through all of your `Systems` you should `world.tick()` before you do again. 84 | 85 | ## registerComponent 86 | 87 | Register a new `Component` type in your `World`. 88 | 89 | 90 | ```js 91 | class Position extends ApeECS.Component { 92 | 93 | init() { // optional 94 | this.coord = `${this.x}x${this.y}`; 95 | } 96 | 97 | preDestroy() { //optional 98 | console.log('Boom?'); 99 | } 100 | 101 | get coord() { 102 | return `${this.x}x${this.y}`; 103 | } 104 | 105 | } 106 | Position.properties = { 107 | x: 0, 108 | y: 0, 109 | coord: '0x0', 110 | parent: ApeECS.EntityRef 111 | }; 112 | Position.serialize = true; // optional, default 113 | Position.serializeFields = ['x', 'y']; // optional 114 | // default is null, when serializeFields not specified, all properties are serialized 115 | 116 | world.registerComponent(Position, 100); 117 | ``` 118 | 119 | ### Arguments 120 | * definition: `` 121 | * poolSize: `Number`, integer, default number of this `Component` to create for a memory pool 122 | 123 | 124 | Registers a new Component type to be used with `world.createEntity()` or `entity.addComponent()`. 125 | 126 | ☝️ If your Component doesn't need any properties or special options, register a Tag instead with `world.registerTag()`. 127 | 128 | 👀 See the [Component docs](./Component.md) in learn how to properly extend `ApeECS.Component`. 129 | 130 | ## registerTags 131 | 132 | ```js 133 | world.registerTags('MarkForDelete', 'Invisible', 'IsSandwich'); 134 | ``` 135 | 136 | Registers any tags that you'll be using for your Entities. 137 | 138 | ### Arguments 139 | * ...tags: `String` 140 | 141 | Tags are used like `Components`, but they're only a string. You can check if `entity.has('TagName')` just like you would a `Component` name, and use them similarly in `Queryies`. They're using for knowing if an `Entity` "is" something, but you don't need any further properties to describe that aspect. 142 | 143 | ## createEntity 144 | 145 | Create a new Entity, including any initial components (and their initial property values) or tags. It returns a new `Entity` instance. 146 | 147 | ```js 148 | const playerEntity = world.createEntity({ 149 | id: 'Player', // optional 150 | tags: ['Character', 'Visible'], //optional 151 | components: [ // optional 152 | { 153 | type: 'Slot', 154 | name: 'Left Hand', 155 | slotType: 'holdable' 156 | }, 157 | { 158 | type: 'Slot', 159 | name: 'Right Hand', 160 | slotType: 'holdable' 161 | }, 162 | { 163 | type: 'Slot', 164 | name: 'Body' 165 | slotType: 'body' 166 | }, 167 | ], 168 | c: { // optional 169 | Controls: { 170 | boundTo: 'keyboard', 171 | keysDown: [] 172 | }, 173 | Position: { 174 | x: 15, 175 | y: 23 176 | }, 177 | Inventory: { 178 | type: 'Container' // specified type different than the key 179 | size: 16 180 | }, 181 | footSlot: { 182 | type: 'Slot' 183 | name: 'Feet' 184 | slotType: 'shoes' 185 | } 186 | } 187 | }); 188 | ``` 189 | 190 | ### Arguments: 191 | * definition `Object`, _required_ 192 | * id: `String`, _optional_, unique identifier (generated if not specified) 193 | * tags: `[]String`, _optional_, any registered tags to include 194 | * components: `[]Object`, _optional_, initial components 195 | * type: `String`, _required_, registered component type 196 | * id: `String`, _optional_, unique identifier (generated if not specified) 197 | * key: `String`, _optional_, key value of the component instance in the `Entity`. If not specified, tye component instance has no key value, and is only included in the `Set` of `entity.components['ComponentType']` values. 198 | * \*properties: initial values for defined properties of the component 199 | * c: `Object`: _optional, Components indexed by a key value. Equivalant to specifying the `key` and `type` in the `components` array property. 200 | * `key` is the `key` value of the component instance. Also the component type if one is not specified. 201 | * `value` is an `Object` defining the initial values of a `Component` 202 | * `type`: `String`, _optional_, If not specified, the `key` key needs to be a registered `Component` type. 203 | * `id`: `String`, _optional_, unique identifier (generated if not specified) 204 | * \*properties: initial values for defined properties of the component 205 | 206 | ### Notes: 207 | 👀 For more information on how keys work, see the [Entity Docs](Entity.md). 208 | 209 | ☝️ The createEntity definition schema is the same one used by `entity.getObject` and `world.getObject`. As such, you can save and restore objects by saving the results of these methods and calling `world.createEntity` with the same `Object` to restore it. 210 | 211 | 💭 **Ape ECS** uses a very fast unique id generator for `Components` and `Entities` if you don't specify a given id upon creation. Look at the code in [src/util.js](../src/util.js). 212 | 213 | ## getObject 214 | 215 | Retrieves a serializable object that includes all of the Entities and their Components in the World. 216 | Returns an array of Entity definitions. See [world.createEntity](#createentity); 217 | 218 | ```js 219 | const saveState = world.getObject(); 220 | const jsonState = JSON.stringify(saveState); 221 | ``` 222 | 223 | ## createEntities 224 | 225 | Just like [world.createEntity](#createentity) except that it takes an array of `createEntity` definitions. 226 | 227 | ```js 228 | world.createEntities([ 229 | { 230 | tags: ['Tile', 'Visible', 'New'], 231 | components: [ 232 | { 233 | type: 'Sprite': 234 | texture: 'sprites/tiles/ground1.png' 235 | } 236 | ], 237 | c: { 238 | Position: { 239 | x: 0, 240 | y: 0 241 | } 242 | } 243 | }, 244 | { 245 | tags: ['Tile', 'Visible', 'New'], 246 | components: [ 247 | { 248 | type: 'Sprite': 249 | texture: 'sprites/tiles/ground1.png' 250 | } 251 | ], 252 | c: { 253 | Position: { 254 | x: 1, 255 | y: 0 256 | } 257 | } 258 | }, 259 | { 260 | tags: ['Character', 'Player'], 261 | id: 'Player' 262 | c: { 263 | Position: { 264 | x: 0, 265 | y: 0 266 | } 267 | } 268 | } 269 | ]); 270 | ``` 271 | 272 | ## copyTypes 273 | 274 | Copy registered Component types from another world to this one. 275 | 276 | ```js 277 | world.copyTypes(world2, types); 278 | ``` 279 | 280 | ### Arguments: 281 | * world2: `World instance`, _required_ 282 | * types: `[]String`, _required_, Registered `Component` types that you'd like to copy from world2 283 | 284 | ## getEntity 285 | 286 | Get an `Entity` instance by it's `id` or `undefined`. 287 | 288 | ```js 289 | const entity1 = world.getEntity('aabbccdd-1'); 290 | ``` 291 | 292 | ### Arguments 293 | * id: `String`, _required_, The id of an existing `Entity` instance. 294 | 295 | ## removeEntity 296 | 297 | Removes an `Entity` instance from the `world`, initializing its `destroy()`. 298 | 299 | ### Arguments: 300 | * id/entity: `String` or `Entity instance` of an existing `Entity`. 301 | 302 | ### Notes: 303 | 304 | ☝️ Equivalent to: 305 | 306 | ```js 307 | world.getEntity(id).destroy(); 308 | ``` 309 | 310 | ## getEntities 311 | 312 | Retrieve a `Set` of all the `Entities` that include a given `Component` type or Tag. 313 | 314 | ```js 315 | const tiles = world.getEntities('Tile'); 316 | ``` 317 | 318 | ### Notes: 319 | 320 | ☝️ You could also do this with a `Query`. 321 | 322 | ```js 323 | const q1 = world.createQuery().fromAll('Tile'); 324 | const tiles = q1.execute(); 325 | ``` 326 | 327 | ## getComponent 328 | 329 | Get an `Component` instance by its `id`. 330 | 331 | ```js 332 | const component = world.getComponent('alakjds-123'); 333 | ``` 334 | 335 | **Arguments**: 336 | * id: `String`, _required_, unique id of a component 337 | 338 | ## createQuery 339 | 340 | Factory that returns a new `Query` instance. 341 | 342 | ```js 343 | const query1 = world.createQuery({ /* config */}).fromAll('Tile', 'Position'); 344 | const tiles = query1.execute({ /* filter */ }); 345 | ``` 346 | 347 | ### Notes: 348 | 349 | 👀 See the [Query Docs](Query.md) to learn more about creating them and using them. 350 | Queries are a big part of **Ape ECS** and are fairly advanced. 351 | 352 | ☝️ You can also `createQuery` from a `System`. `Systems` can persist `Queries` which then act as an index to results that automatically stays up to date as `Entity` composition changes. 353 | 354 | ## registerSystem 355 | 356 | Registers a `System` class or instance with the world for later execution. 357 | 358 | ```js 359 | class Gravity extends ApeECS.System {} 360 | 361 | world.registerSystem('movement', Gravity); 362 | ``` 363 | 364 | ### Arguments: 365 | * group: `String` name for the group of Systems 366 | * system: `System class` or `System instance` 367 | * initParams: optional array of arguments for system init() 368 | 369 | ### Returns: 370 | * `System` instance. 371 | 372 | You can have one, many, or all of your `Systems` in a single "group." 373 | You might have a "startup" group of `Systems` that initializes a level, a "turn" group that runs for every user turn, and a "frame" group that runs for every rendered frame of animation. 374 | If you always run all of your `Systems` every time, you could just register them all with the same group. 375 | If you want to be able to run any `System` at an time in any order, you could register every `System` with a unique group. 376 | 377 | 378 | ### Notes: 379 | 380 | 👀 See the [System Docs](System.md) for more information about how `Systems` work. 381 | 382 | ## runSystems 383 | 384 | Runs the added `Systems` that were registered with that specific group, in order. 385 | 386 | ```js 387 | world.runSystems(group); 388 | ``` 389 | 390 | ### Notes: 391 | 392 | 👀 See the [System Docs](System.md) for more information about running `Systems`. 393 | 394 | ## getStats 395 | 396 | Get the World stats (currently pooling status) 397 | 398 | ```js 399 | const stats = world.getStats(); 400 | console.log(stats); 401 | ``` 402 | 403 | ```js 404 | { 405 | entity: { active: 23, pooled: 77, target: 100 }, 406 | SomeComponent: { active: 30 pooled: 20, target: 50 }, 407 | SomeOtherComponent: { active: 30 pooled: 0, target: 10 } 408 | } 409 | ``` 410 | 411 | 👆 When you [registerComponent](#registercomponent), the second argument is your target pool size. 412 | 413 | 👆 You can go over your target pool size, and if the world configuration of `cleanupPools` is true (the default), any unused pooled items over double the target will be slowly decreased until somewhere between double and the target. 414 | 415 | ## logStats 416 | 417 | ```js 418 | world.logStats(60, console.log); 419 | ``` 420 | 421 | ``` 422 | 3, Entity 23 active 77/100 pooled 423 | 3, SomeComponent 30 active 20/50 pooled 424 | 3, SomeOtherComponent 30 active 0/10 pooled 425 | ``` 426 | 427 | **Arguments**: 428 | * freq: Number, _required_: How frequently in ticks to call the log 429 | * callback: function, _optional_: function to call with stats logs, default: console.log 430 | 431 | ## updateIndexes 432 | 433 | Method that updates persisted queries after system runs and upon ticks. 434 | 435 | ```js 436 | world.updateIndexes(); 437 | ``` 438 | 439 | You can update the results of persisted queries for changed entities within a system update by calling this method. 440 | It won't do anything for non-system, non-persisted queries. 441 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { expect } from 'chai'; 3 | 4 | import { 5 | System, 6 | World, 7 | Component, 8 | Entity, 9 | EntityRef, 10 | EntitySet, 11 | EntityObject, 12 | Query, 13 | } from '../src'; 14 | import { SSL_OP_NO_TICKET } from 'constants'; 15 | 16 | const ECS = { 17 | World, 18 | System: System, 19 | Component, 20 | }; 21 | 22 | 23 | class Health extends ECS.Component { 24 | static properties = { 25 | max: 25, 26 | hp: 25, 27 | armor: 0 28 | }; 29 | static typeName = 'Health'; 30 | } 31 | 32 | describe('express components', () => { 33 | 34 | const ecs = new ECS.World(); 35 | 36 | ecs.registerComponent(Health); 37 | 38 | it('create entity', () => { 39 | 40 | const S1 = class System extends ECS.System {} 41 | const s1 = new S1(ecs); 42 | 43 | ecs.createEntity({ 44 | components: [ 45 | { 46 | type: 'Health', 47 | key: 'Health', 48 | hp: 10 49 | } 50 | ] 51 | }); 52 | 53 | const results = s1.createQuery({ all: ['Health'] }).execute(); 54 | 55 | expect(results.size).to.equal(1); 56 | }); 57 | 58 | it('create 2nd entity', () => { 59 | 60 | ecs.createEntity({ 61 | c: { 62 | Health: { hp: 10 } 63 | } 64 | }); 65 | 66 | const results = ecs.createQuery().fromAll(Health).execute(); 67 | 68 | expect(results.size).to.equal(2); 69 | }); 70 | 71 | it('entity refs', () => { 72 | 73 | class Storage extends ECS.Component { 74 | static properties = { 75 | name: 'inventory', 76 | size: 20, 77 | items: EntitySet 78 | }; 79 | } 80 | 81 | class EquipmentSlot extends ECS.Component { 82 | static properties = { 83 | name: 'finger', 84 | slot: EntityRef, 85 | effects: [] 86 | }; 87 | } 88 | 89 | class Food extends ECS.Component { 90 | static properties = { 91 | rot: 300, 92 | restore: 2 93 | }; 94 | } 95 | 96 | ecs.registerComponent(Storage); 97 | ecs.registerComponent(EquipmentSlot); 98 | ecs.registerComponent(Food); 99 | 100 | const food = ecs.createEntity({ 101 | id: 'sandwich10', // to exersize custom id 102 | components: [ 103 | { 104 | type: 'Food', 105 | key: 'Food' 106 | } 107 | ] 108 | }); 109 | 110 | const entity = ecs.createEntity({ 111 | c: { 112 | pockets: { type: 'Storage', size: 4 }, 113 | backpack: { type: 'Storage', size: 25 }, 114 | pants: { type: 'EquipmentSlot' }, 115 | shirt: { type: 'EquipmentSlot' }, 116 | Health: { 117 | hp: 10, 118 | max: 10 119 | } 120 | } 121 | }); 122 | 123 | 124 | entity.c.pockets.items.add(food); 125 | expect(entity.c.pockets.items.has(food)).to.be.true; 126 | 127 | const entityObj = entity.getObject(false); 128 | delete entityObj.id; 129 | const eJson = JSON.stringify(entityObj); 130 | const entityDef = JSON.parse(eJson); 131 | 132 | const entity2 = ecs.createEntity(entityDef); 133 | 134 | expect(entity.c.pockets.items.has(food)).to.be.true; 135 | expect(entity2.c.pockets.items.has(food)).to.be.true; 136 | 137 | ecs.removeEntity(food); 138 | 139 | expect(entity.c.pockets.items.has(food)).to.be.false; 140 | expect(entity2.c.pockets.items.has(food)).to.be.false; 141 | 142 | expect(ecs.getEntity(food.id)).to.be.undefined; 143 | ecs.removeEntity(entity.id); 144 | 145 | expect(ecs.getEntity(entity.id)).to.be.undefined; 146 | 147 | }); 148 | 149 | it('init and destroy component', () => { 150 | 151 | let hit = false; 152 | 153 | const ecs = new ECS.World(); 154 | 155 | class Test extends ECS.Component { 156 | static properties = { 157 | x: null, 158 | y: 0 159 | }; 160 | 161 | preDestroy() { 162 | this.x = null; 163 | hit = true; 164 | } 165 | 166 | init() { 167 | this.y++; 168 | } 169 | 170 | } 171 | ecs.registerComponent(Test); 172 | 173 | const entity = ecs.createEntity({ 174 | c: { 175 | Test: { 176 | } 177 | } 178 | }); 179 | 180 | 181 | expect(entity.c.Test.y).to.equal(1); 182 | expect(hit).to.equal(false); 183 | 184 | entity.removeComponent(entity.c.Test); 185 | expect(hit).to.equal(true); 186 | 187 | }); 188 | 189 | it('system subscriptions', () => { 190 | 191 | let changes = []; 192 | let changes2 = []; 193 | let effectExt = null; 194 | /* $lab:coverage:off$ */ 195 | class System extends ECS.System { 196 | 197 | init(a, b) { 198 | 199 | this.subscribe('EquipmentSlot'); 200 | expect(a).to.equal(1); 201 | expect(b).to.equal('b2'); 202 | } 203 | 204 | update(tick) { 205 | 206 | changes = this.changes; 207 | for (const change of this.changes) { 208 | const parent = this.world.getEntity(change.entity); 209 | if (change.op === 'addRef') { 210 | const value = this.world.getEntity(change.target); 211 | if (value.has(Wearable)) { 212 | const components = []; 213 | for (const effectDef of value.c.Wearable.effects) { 214 | const component = parent.addComponent(effectDef); 215 | components.push(component); 216 | } 217 | if (components.length > 0) { 218 | const effect = parent.addComponent({ 219 | type: 'EquipmentEffect', 220 | equipment: value.id 221 | }); 222 | for (const c of components) { 223 | effect.effects.push(c.id); 224 | effectExt = c; 225 | } 226 | } 227 | } 228 | } else if (change.op === 'deleteRef') { 229 | for (const effect of parent.getComponents('EquipmentEffect')) { 230 | if (effect.equipment === change.target) { 231 | for (const compid of effect.effects) { 232 | const comp = this.world.getComponent(compid); 233 | parent.removeComponent(comp); 234 | } 235 | parent.removeComponent(effect); 236 | } 237 | } 238 | } 239 | } 240 | } 241 | } 242 | 243 | class System2 extends ECS.System { 244 | 245 | init(a, b, c) { 246 | 247 | expect(a).to.equal(2); 248 | expect(b).to.equal(4); 249 | expect(c).to.equal('a'); 250 | } 251 | 252 | update(tick) { 253 | 254 | changes2 = this.changes; 255 | } 256 | } 257 | System2.subscriptions = ['EquipmentSlot']; 258 | /* $lab:coverage:on */ 259 | 260 | class EquipmentEffect extends ECS.Component { 261 | static properties = { 262 | equipment: '', 263 | effects: [] 264 | }; 265 | } 266 | 267 | class Wearable extends ECS.Component { 268 | static properties = { 269 | name: 'ring', 270 | effects: [ 271 | { type: 'Burning' } 272 | ] 273 | }; 274 | } 275 | 276 | class Burning extends ECS.Component {}; 277 | 278 | ecs.registerComponent(EquipmentEffect); 279 | ecs.registerComponent(Wearable); 280 | ecs.registerComponent(Burning); 281 | 282 | const system = new System(ecs, 1, 'b2'); 283 | //const system2 = new System2(ecs); 284 | 285 | ecs.registerSystem('equipment', system); 286 | ecs.registerSystem('equipment', System2, [2, 4, 'a']); 287 | 288 | ecs.runSystems('equipment'); 289 | 290 | const entity = ecs.createEntity({ 291 | c: { 292 | pockets: { type: 'Storage', size: 4 }, 293 | backpack: { type: 'Storage', size: 25 }, 294 | pants: { type: 'EquipmentSlot' }, 295 | shirt: { type: 'EquipmentSlot' }, 296 | Health: { 297 | hp: 10, 298 | max: 10 299 | } 300 | } 301 | }); 302 | 303 | const pants = ecs.createEntity({ 304 | c: { 305 | Wearable: { name: 'Nice Pants', 306 | effects: [ 307 | { type: 'Burning' } 308 | ] 309 | } 310 | } 311 | }); 312 | 313 | ecs.runSystems('equipment'); 314 | expect(changes.length).to.equal(2); 315 | 316 | entity.c.pants.slot = pants; 317 | 318 | ecs.runSystems('equipment'); 319 | 320 | expect(entity.getComponents('EquipmentEffect')).to.not.be.empty; 321 | expect(entity.getComponents(EquipmentEffect)).to.not.be.empty; 322 | const eEffects = new Set([...entity.getComponents('EquipmentEffect')][0].effects); 323 | 324 | expect(eEffects.has(effectExt.id)).to.be.true; 325 | expect(entity.getComponents('Burning')).to.not.be.empty; 326 | expect(changes.length).to.equal(1); 327 | expect(changes[0].op).to.equal('addRef'); 328 | expect(changes[0].target).to.equal(pants.id); 329 | 330 | //entity.EquipmentSlot.pants.slot = null; 331 | pants.destroy(); 332 | ecs.runSystems('equipment'); 333 | 334 | ecs.runSystems('asdf'); //code path for non-existant system 335 | expect(changes2.length).to.be.greaterThan(0); 336 | expect(changes.length).to.be.greaterThan(0); 337 | expect(changes[0].target).to.equal(pants.id); 338 | expect(entity.getComponents('EquipmentEffect')).to.be.empty; 339 | expect(entity.getComponents('Burning')).to.be.empty; 340 | 341 | }); 342 | 343 | 344 | 345 | it('system subscriptions with updated components', () => { 346 | 347 | let changes = []; 348 | 349 | class Food2 extends ECS.Component { 350 | static properties = { 351 | rot: 300, 352 | restore: 2 353 | }; 354 | static changeEvents = true; 355 | } 356 | 357 | class System extends ECS.System { 358 | 359 | init() { 360 | 361 | this.subscribe(Food2); 362 | } 363 | 364 | update(tick) { 365 | 366 | for (const cng of this.changes) { 367 | changes.push(cng); 368 | } 369 | } 370 | } 371 | 372 | 373 | ecs.registerComponent(Food2); 374 | const system = new System(ecs); 375 | ecs.registerSystem('equipment', system); 376 | ecs.runSystems('equipment'); 377 | 378 | const e0 = ecs.createEntity({ 379 | c: { 380 | food: { type: 'Food2', rot: 4 }, 381 | } 382 | }); 383 | 384 | ecs.runSystems('equipment'); 385 | e0.removeComponent(e0.c.food); 386 | ecs.runSystems('equipment'); 387 | 388 | const e1 = ecs.createEntity({ 389 | c: { 390 | food: { type: 'Food2', rot: 5 }, 391 | } 392 | }); 393 | 394 | ecs.runSystems('equipment'); 395 | 396 | expect(e1.c.food.rot).to.equal(5); 397 | expect(e1.c.food.restore).to.equal(2); 398 | 399 | e1.c.food.update({rot:6}); 400 | 401 | expect(e1.c.food.rot).to.equal(6); 402 | expect(e1.c.food.restore).to.equal(2); 403 | 404 | ecs.runSystems('equipment'); 405 | 406 | e1.c.food.update({rot:0,restore:0}); 407 | 408 | expect(e1.c.food.rot).to.equal(0); 409 | expect(e1.c.food.restore).to.equal(0); 410 | 411 | ecs.runSystems('equipment'); 412 | 413 | expect(e1.c.food.rot).to.equal(0); 414 | expect(e1.c.food.restore).to.equal(0); 415 | 416 | expect(changes[0].op).to.equal('add'); 417 | expect(changes[1].op).to.equal('destroy'); 418 | expect(changes[2].op).to.equal('add'); 419 | expect(changes[3].op).to.equal('change'); 420 | expect(changes[3].props).to.be.eql(['rot']); 421 | expect(changes[4].props).to.be.eql(['rot', 'restore']); 422 | }); 423 | }); 424 | 425 | describe('system queries', () => { 426 | 427 | const ecs = new ECS.World(); 428 | 429 | it('add and remove forbidden component', () => { 430 | 431 | class Tile extends ECS.Component { 432 | static properties = { 433 | x: 0, 434 | y: 0, 435 | level: 0 436 | }; 437 | } 438 | 439 | class Hidden extends ECS.Component {} 440 | 441 | ecs.registerComponent(Tile); 442 | ecs.registerComponent(Hidden); 443 | 444 | class TileSystem extends ECS.System { 445 | 446 | lastResults: Set; 447 | query: Query; 448 | 449 | init() { 450 | 451 | this.lastResults = new Set(); 452 | this.query = this.world.createQuery({ 453 | all: ['Tile'], 454 | not: ['Hidden'], 455 | persist: true }); 456 | } 457 | 458 | update(tick) { 459 | 460 | this.lastResults = this.query.execute(); 461 | } 462 | } 463 | 464 | const tileSystem = new TileSystem(ecs); 465 | ecs.registerSystem('map', tileSystem); 466 | 467 | ecs.runSystems('map'); 468 | 469 | expect(tileSystem.lastResults.size).to.equal(0); 470 | 471 | const tile1 = ecs.createEntity({ 472 | c: { 473 | Tile: { 474 | x: 10, 475 | y: 0, 476 | level: 0 477 | } 478 | } 479 | }); 480 | 481 | const tile2 = ecs.createEntity({ 482 | c: { 483 | Tile: { 484 | x: 11, 485 | y: 0, 486 | level: 0 487 | }, 488 | Hidden: {} 489 | } 490 | }); 491 | 492 | ecs.tick() 493 | 494 | ecs.runSystems('map'); 495 | 496 | expect(tileSystem.lastResults.size).to.equal(1); 497 | expect(tileSystem.lastResults.has(tile1)).to.be.true; 498 | 499 | tile2.removeComponent(tile2.c.Hidden); 500 | ecs.tick(); 501 | 502 | ecs.runSystems('map'); 503 | 504 | expect(tileSystem.lastResults.size).to.equal(2); 505 | expect(tileSystem.lastResults.has(tile1)).to.be.true; 506 | expect(tileSystem.lastResults.has(tile1)).to.be.true; 507 | 508 | tile1.addComponent({ type: 'Hidden' }); 509 | ecs.updateIndexes(); 510 | 511 | ecs.runSystems('map'); 512 | 513 | expect(tileSystem.lastResults.size).to.equal(1); 514 | expect(tileSystem.lastResults.has(tile2)).to.be.true; 515 | 516 | 517 | }); 518 | 519 | it('multiple has and hasnt', () => { 520 | 521 | class Billboard extends ECS.Component {}; 522 | class Sprite extends ECS.Component {}; 523 | ecs.registerComponent(Billboard); 524 | ecs.registerComponent(Sprite); 525 | 526 | const tile1 = ecs.createEntity({ 527 | c: { 528 | Tile: {}, 529 | Billboard: {}, 530 | Sprite: {}, 531 | Hidden: {} 532 | } 533 | }); 534 | 535 | const tile2 = ecs.createEntity({ 536 | c: { 537 | Tile: {}, 538 | Billboard: {}, 539 | } 540 | }); 541 | 542 | const tile3 = ecs.createEntity({ 543 | c: { 544 | Tile: {}, 545 | Billboard: {}, 546 | Sprite: {} 547 | } 548 | }); 549 | 550 | const tile4 = ecs.createEntity({ 551 | c: { 552 | Tile: {}, 553 | } 554 | }); 555 | 556 | const tile5 = ecs.createEntity({ 557 | c: { 558 | Billboard: {}, 559 | } 560 | }); 561 | 562 | const result = ecs.createQuery() 563 | .fromAll('Tile', 'Billboard') 564 | .not(Sprite, 'Hidden') 565 | .execute(); 566 | 567 | const resultSet = new Set([...result]); 568 | 569 | expect(resultSet.has(tile1)).to.be.false; 570 | expect(resultSet.has(tile2)).to.be.true; 571 | expect(resultSet.has(tile3)).to.be.false; 572 | expect(resultSet.has(tile4)).to.be.false; 573 | expect(resultSet.has(tile5)).to.be.false; 574 | 575 | }); 576 | 577 | it('tags', () => { 578 | 579 | const ecs = new ECS.World(); 580 | class Tile extends ECS.Component {}; 581 | class Sprite extends ECS.Component {}; 582 | 583 | ecs.registerComponent(Tile); 584 | ecs.registerComponent(Sprite); 585 | ecs.registerTags('Billboard', 'Hidden'); 586 | 587 | const tile1 = ecs.createEntity({ 588 | tags: ['Billboard', 'Hidden'], 589 | c: { 590 | Tile: {} 591 | } 592 | }); 593 | 594 | const tile2 = ecs.createEntity({ 595 | tags: ['Billboard'], 596 | c: { 597 | Tile: {} 598 | } 599 | }); 600 | 601 | const tile3 = ecs.createEntity({ 602 | tags: ['Billboard'], 603 | c: { 604 | Sprite: {} 605 | } 606 | }); 607 | 608 | const tile4 = ecs.createEntity({ 609 | c: { 610 | Tile: {}, 611 | } 612 | }); 613 | 614 | const tile5 = ecs.createEntity({ 615 | tags: ['Billboard'] 616 | }); 617 | 618 | const q1 = ecs.createQuery({ 619 | all: ['Tile', 'Billboard'], 620 | not: ['Sprite', 'Hidden'], 621 | }).persist(); 622 | const result = q1.execute(); 623 | 624 | const resultSet = new Set([...result]); 625 | 626 | expect(resultSet.has(tile1)).to.be.false; 627 | expect(resultSet.has(tile2)).to.be.true; 628 | expect(tile2.has('Sprite')).to.be.false; 629 | expect(resultSet.has(tile3)).to.be.false; 630 | expect(resultSet.has(tile4)).to.be.false; 631 | expect(resultSet.has(tile5)).to.be.false; 632 | 633 | const result3 = ecs.getEntities('Hidden'); 634 | 635 | expect(result3.has(tile1)).to.be.true; 636 | expect(result3.has(tile2)).to.be.false; 637 | expect(result3.has(tile3)).to.be.false; 638 | expect(result3.has(tile4)).to.be.false; 639 | expect(result3.has(tile5)).to.be.false; 640 | 641 | const result3b = ecs.getEntities(Sprite); 642 | 643 | expect(result3b.has(tile1)).to.be.false; 644 | expect(result3b.has(tile2)).to.be.false; 645 | expect(result3b.has(tile3)).to.be.true; 646 | expect(result3b.has(tile4)).to.be.false; 647 | expect(result3b.has(tile5)).to.be.false; 648 | 649 | tile4.addTag('Billboard'); 650 | tile2.removeTag('Hidden'); 651 | tile1.removeTag('Hidden'); 652 | tile3.addComponent({ type: 'Tile' }); 653 | tile3.addTag('Hidden'); 654 | tile1.removeTag('Billboard'); 655 | tile4.addTag('Hidden'); 656 | 657 | const result2 = q1.results; 658 | 659 | expect(tile4.has('Billboard')).to.be.true; 660 | expect(tile3.has('Tile')).to.be.true; 661 | expect(result2.has(tile1)).to.be.false; 662 | expect(result2.has(tile2)).to.be.true; 663 | expect(result2.has(tile3)).to.be.false; 664 | expect(result2.has(tile4)).to.be.false; 665 | expect(result2.has(tile5)).to.be.false; 666 | 667 | }); 668 | 669 | it('filter by updatedValues', () => { 670 | 671 | const ecs = new ECS.World(); 672 | class Comp1 extends ECS.Component { 673 | static properties = { 674 | greeting: 'hi' 675 | }; 676 | } 677 | ecs.registerComponent(Comp1); 678 | 679 | ecs.tick(); 680 | 681 | const entity1 = ecs.createEntity({ 682 | c: { 683 | Comp1: {} 684 | } 685 | }); 686 | 687 | const entity2 = ecs.createEntity({ 688 | c: { 689 | Comp1: { 690 | greeting: 'hullo' 691 | } 692 | } 693 | }); 694 | 695 | ecs.tick(); 696 | const ticks = ecs.currentTick; 697 | const testQ = ecs.createQuery().fromAll('Comp1').persist(); 698 | const results1 = testQ.execute(); 699 | expect(results1.has(entity1)).to.be.true; 700 | expect(results1.has(entity2)).to.be.true; 701 | }); 702 | 703 | it('filter by updatedComponents', () => { 704 | 705 | const ecs = new ECS.World(); 706 | class Comp1 extends ECS.Component { 707 | static properties = { 708 | greeting: 'hi' 709 | }; 710 | } 711 | class Comp2 extends ECS.Component {} 712 | ecs.registerComponent(Comp1); 713 | ecs.registerComponent(Comp2); 714 | 715 | ecs.tick(); 716 | 717 | const entity1 = ecs.createEntity({ 718 | c: { 719 | Comp1: {} 720 | } 721 | }); 722 | 723 | const entity2 = ecs.createEntity({ 724 | c: { 725 | Comp1: { 726 | greeting: 'hullo' 727 | } 728 | } 729 | }); 730 | 731 | ecs.tick(); 732 | const ticks = ecs.currentTick; 733 | const testQ = ecs.createQuery().fromAll('Comp1').persist(true, true); 734 | const results1 = testQ.execute(); 735 | expect(testQ.trackAdded).to.be.true; 736 | expect(testQ.trackRemoved).to.be.true; 737 | expect(results1.has(entity1)).to.be.true; 738 | expect(results1.has(entity2)).to.be.true; 739 | 740 | const comp1 = entity1.c.Comp1; 741 | comp1.greeting = 'Gutten Tag'; 742 | comp1.update(); 743 | entity2.addComponent({ type: 'Comp2' }); 744 | 745 | const results2 = testQ.execute({ updatedComponents: ticks }); 746 | expect(results2.has(entity1)).to.be.false; 747 | expect(results2.has(entity2)).to.be.true; 748 | 749 | }); 750 | 751 | it('destroyed entity should be cleared', () => { 752 | 753 | const ecs = new ECS.World(); 754 | class Comp1 extends ECS.Component {} 755 | ecs.registerComponent(Comp1); 756 | 757 | const entity1 = ecs.createEntity({ 758 | c: { 759 | Comp1: {} 760 | } 761 | }); 762 | 763 | const query = ecs.createQuery().fromAll('Comp1').persist(); 764 | const results1 = query.execute(); 765 | expect(results1.has(entity1)).to.be.true; 766 | 767 | entity1.destroy(); 768 | 769 | ecs.tick(); 770 | 771 | const results2 = query.execute(); 772 | expect(results2.has(entity1)).to.be.false; 773 | 774 | }); 775 | }); 776 | 777 | 778 | describe('entity & component refs', () => { 779 | 780 | const ecs = new ECS.World(); 781 | 782 | class BeltSlots extends ECS.Component { 783 | static properties = { 784 | slots: EntityObject, 785 | }; 786 | } 787 | class Potion extends ECS.Component {} 788 | 789 | ecs.registerComponent(BeltSlots); 790 | ecs.registerComponent(Potion); 791 | 792 | it('Entity Object', () => { 793 | 794 | const belt = ecs.createEntity({ 795 | c: { 796 | BeltSlots: {} 797 | } 798 | }); 799 | 800 | const slots = ['a', 'b', 'c']; 801 | const potions = []; 802 | const beltslots = belt.c.BeltSlots; 803 | for (const slot of slots) { 804 | const potion = ecs.createEntity({ 805 | c: { 806 | Potion: {} 807 | } 808 | }); 809 | beltslots.slots[slot] = potion; 810 | potions.push(potion); 811 | } 812 | 813 | const potionf = ecs.createEntity({ 814 | c: { 815 | Potion: {} 816 | } 817 | }); 818 | 819 | //expect(beltslots.slots[Symbol.iterator]).to.not.exist; 820 | 821 | expect(beltslots.slots.a).to.equal(potions[0]); 822 | expect(beltslots.slots.b).to.equal(potions[1]); 823 | expect(beltslots.slots.c).to.equal(potions[2]); 824 | 825 | potions[1].destroy(); 826 | expect(beltslots.slots.b).to.not.exist; 827 | 828 | delete beltslots.slots.c; 829 | expect(beltslots.slots.c).to.not.exist; 830 | 831 | //assign again 832 | beltslots.slots['a'] = potions[0]; 833 | 834 | //assign by id 835 | beltslots.slots.a = potionf.id; 836 | expect(beltslots.slots.a).to.equal(potionf); 837 | 838 | // Calling delete on a EntityObject component that does 839 | // not exist should return false 840 | // when in strict mode, this will throw an exception 841 | expect(()=>{delete beltslots.slots.d}).to.throw(TypeError); 842 | }); 843 | 844 | it('Entity Set', () => { 845 | 846 | class BeltSlots2 extends ECS.Component { 847 | static properties = { 848 | slots: EntitySet, 849 | }; 850 | } 851 | ecs.registerComponent(BeltSlots2); 852 | 853 | const belt = ecs.createEntity({ 854 | c: { 855 | BeltSlots2: {} 856 | } 857 | }); 858 | 859 | const slots = ['a', 'b', 'c']; 860 | const potions = []; 861 | const beltSlots2 = belt.c.BeltSlots2; 862 | for (const slot of slots) { 863 | const potion = ecs.createEntity({ 864 | c: { 865 | Potion: {} 866 | } 867 | }); 868 | beltSlots2.slots.add(potion); 869 | potions.push(potion); 870 | } 871 | 872 | expect(beltSlots2.slots[Symbol.iterator]).to.exist; 873 | 874 | expect(beltSlots2.slots).instanceof(Set); 875 | expect(beltSlots2.slots.has(potions[0])).to.be.true; 876 | expect(beltSlots2.slots.has(potions[1])).to.be.true; 877 | expect(beltSlots2.slots.has(potions[2])).to.be.true; 878 | 879 | const withValues = ecs.createEntity({ 880 | c: { 881 | BeltSlots: { slots: { a: potions[0].id, b: potions[2], d: null }} 882 | } 883 | }); 884 | 885 | expect(withValues.c.BeltSlots.slots.a).to.equal(potions[0]); 886 | expect(withValues.c.BeltSlots.slots.b).to.equal(potions[2]); 887 | 888 | withValues.c.BeltSlots.slots.c = potions[1].id; 889 | expect(withValues.c.BeltSlots.slots.c).to.equal(potions[1]); 890 | 891 | withValues.c.BeltSlots.slots.c = null; 892 | expect(withValues.c.BeltSlots.slots.c).to.equal(undefined); 893 | withValues.c.BeltSlots.slots.c = potions[1]; 894 | expect(withValues.c.BeltSlots.slots.c).to.equal(potions[1]); 895 | 896 | 897 | }); 898 | 899 | class Crying extends ECS.Component {} 900 | class Angry extends ECS.Component {} 901 | ecs.registerComponent(Crying); 902 | ecs.registerComponent(Angry); 903 | 904 | it('Assign entity ref by id', () => { 905 | 906 | class Ref extends ECS.Component { 907 | static properties = { 908 | other: EntityRef 909 | }; 910 | } 911 | ecs.registerComponent(Ref); 912 | 913 | const entity = ecs.createEntity({ 914 | c: { 915 | Crying: {} 916 | } 917 | }); 918 | 919 | const entity2 = ecs.createEntity({ 920 | c: { 921 | Ref: { other: entity.id } 922 | } 923 | }); 924 | 925 | expect(entity2.c.Ref.other).to.equal(entity); 926 | }); 927 | 928 | it('Reassign same entity ref', () => { 929 | 930 | const entity = ecs.createEntity({ 931 | c: { 932 | Crying: {} 933 | } 934 | }); 935 | 936 | const entity2 = ecs.createEntity({ 937 | c: { 938 | Ref: { other: entity.id } 939 | } 940 | }); 941 | 942 | entity2.c.Ref.update({ other: entity }); 943 | 944 | expect(entity2.c.Ref.other).to.equal(entity); 945 | }); 946 | 947 | }); 948 | 949 | describe('entity restore', () => { 950 | 951 | it('restore mapped object', () => { 952 | 953 | const ecs = new ECS.World(); 954 | ecs.registerTags('Potion'); 955 | 956 | class EquipmentSlot extends ECS.Component { 957 | static properties = { 958 | name: 'finger', 959 | slot: EntityRef 960 | }; 961 | } 962 | ecs.registerComponent(EquipmentSlot); 963 | 964 | 965 | const potion1 = ecs.createEntity({ 966 | tags: ['Potion'] 967 | }); 968 | const potion2 = ecs.createEntity({ 969 | tags: ['Potion'] 970 | }); 971 | 972 | const entity = ecs.createEntity({ 973 | c: { 974 | 'main': { slot: potion1, type: 'EquipmentSlot' }, 975 | 'secondary': { slot: potion2, type: 'EquipmentSlot' } 976 | } 977 | }); 978 | 979 | expect(entity.c.main.slot).to.equal(potion1); 980 | expect(entity.c.secondary.slot).to.equal(potion2); 981 | expect(potion1).to.not.equal(potion2); 982 | }); 983 | 984 | it('restore unmapped object', () => { 985 | 986 | const ecs = new ECS.World(); 987 | ecs.registerTags('Potion'); 988 | 989 | class EquipmentSlot extends ECS.Component { 990 | static properties = { 991 | name: 'finger', 992 | slot: EntityRef 993 | }; 994 | } 995 | ecs.registerComponent(EquipmentSlot); 996 | 997 | 998 | const potion1 = ecs.createEntity({ 999 | tags: ['Potion'] 1000 | }); 1001 | const potion2 = ecs.createEntity({ 1002 | tags: ['Potion'] 1003 | }); 1004 | const potion3 = ecs.createEntity({ 1005 | tags: ['Potion'] 1006 | }); 1007 | 1008 | const entity = ecs.createEntity({ 1009 | components: [ 1010 | { 1011 | type: 'EquipmentSlot', 1012 | key: 'slot3', 1013 | name: 'slot3', 1014 | slot: potion3 1015 | } 1016 | ], 1017 | c: { 1018 | slot1: { type: 'EquipmentSlot', name: 'slot1', slot: potion1 }, 1019 | slot2: { type: 'EquipmentSlot', name: 'slot2', slot: potion2 } 1020 | } 1021 | }); 1022 | 1023 | expect(entity.c.slot1.slot).to.equal(potion1); 1024 | expect(entity.c.slot1.name).to.equal('slot1'); 1025 | expect(entity.c.slot2.slot).to.equal(potion2); 1026 | expect(entity.c.slot2.name).to.equal('slot2'); 1027 | expect(entity.c.slot3.slot).to.equal(potion3); 1028 | expect(entity.c.slot3.name).to.equal('slot3'); 1029 | }); 1030 | 1031 | it('Unregistered component throws', () => { 1032 | 1033 | const ecs = new ECS.World(); 1034 | ecs.registerComponent(class Potion extends ECS.Component {}); 1035 | 1036 | const badName = () => { 1037 | const entity = ecs.createEntity({ 1038 | c: { 1039 | Posion: {} //misspelled 1040 | } 1041 | }); 1042 | }; 1043 | expect(badName).to.throw(); 1044 | }); 1045 | 1046 | it('Unassigned field is not set', () => { 1047 | 1048 | const ecs = new ECS.World(); 1049 | class Potion extends ECS.Component {}; 1050 | ecs.registerComponent(Potion); 1051 | const entity = ecs.createEntity({ 1052 | c: { 1053 | Potion: { x: 37 } 1054 | } 1055 | }); 1056 | expect(entity.c.Potion.x).to.be.undefined; 1057 | }); 1058 | 1059 | it('removeComponentByName many', () => { 1060 | 1061 | const ecs = new ECS.World(); 1062 | ecs.registerComponent(class NPC extends ECS.Component {}); 1063 | ecs.registerComponent(class Other extends ECS.Component {}); 1064 | ecs.registerComponent(class Armor extends ECS.Component { 1065 | static properties = { 'amount': 5 }; 1066 | }); 1067 | 1068 | const entity = ecs.createEntity({ 1069 | c: { 1070 | NPC: {}, 1071 | } 1072 | }); 1073 | entity.addComponent({ type: 'Armor', amount: 10 }); 1074 | entity.addComponent({ type: 'Armor', amount: 30 }); 1075 | 1076 | const entity2 = ecs.createEntity({ 1077 | c: { 1078 | Other: {} 1079 | } 1080 | }); 1081 | 1082 | expect(entity.has('NPC')).to.be.true; 1083 | expect(entity.has('Armor')).to.be.true; 1084 | const armors = entity.getComponents('Armor'); 1085 | expect(armors.size).to.equal(2); 1086 | expect([...armors][0].amount).to.equal(10); 1087 | expect([...armors][1].amount).to.equal(30); 1088 | 1089 | entity.removeComponent([...armors][0]); 1090 | const armors2 = entity.getComponents('Armor'); 1091 | expect(armors2.size).to.equal(1); 1092 | 1093 | const others = entity2.getComponents('Other'); 1094 | expect(others.size).to.equal(1); 1095 | const removed = entity2.removeComponent([...others][0]); 1096 | 1097 | const removed2 = entity2.removeComponent('nonexistant'); 1098 | 1099 | expect(removed).to.be.true; 1100 | expect(removed2).to.be.false; 1101 | 1102 | }); 1103 | 1104 | it('EntitySet', () => { 1105 | 1106 | const ecs = new ECS.World(); 1107 | class SetInventory extends ECS.Component { 1108 | static properties = { 1109 | slots: EntitySet 1110 | }; 1111 | } 1112 | class Bottle extends ECS.Component {} 1113 | class ThrowAway extends ECS.Component { 1114 | static properties = { 1115 | a: 1, 1116 | }; 1117 | static serialize = false; 1118 | } 1119 | ecs.registerComponent(SetInventory); 1120 | ecs.registerComponent(Bottle); 1121 | ecs.registerComponent(ThrowAway); 1122 | 1123 | const container = ecs.createEntity({ 1124 | c: { 1125 | SetInventory: {}, 1126 | ThrowAway: {} 1127 | } 1128 | }); 1129 | 1130 | const bottle1 = ecs.createEntity({ 1131 | c: { 1132 | Bottle: {} 1133 | } 1134 | }); 1135 | const bottle2 = ecs.createEntity({ 1136 | c: { 1137 | Bottle: {} 1138 | } 1139 | }); 1140 | const bottle3 = ecs.createEntity({ 1141 | c: { 1142 | Bottle: {} 1143 | } 1144 | }); 1145 | 1146 | const setInv = container.c.SetInventory; 1147 | setInv.slots.add(bottle1); 1148 | setInv.slots.add(bottle2); 1149 | 1150 | expect(setInv.slots.has(bottle1.id)).to.be.true; 1151 | expect(setInv.slots.has(bottle2)).to.be.true; 1152 | expect(setInv.slots.has(bottle3)).to.be.false; 1153 | 1154 | const def = container.getObject(false); 1155 | const defS = JSON.stringify(def); 1156 | const def2 = JSON.parse(defS); 1157 | delete def2.id; 1158 | 1159 | const container2 = ecs.createEntity(def2); 1160 | const setInv2 = container2.c.SetInventory; 1161 | expect(setInv2.slots.has(bottle1)).to.be.true; 1162 | expect(setInv2.slots.has(bottle2)).to.be.true; 1163 | expect(setInv2.slots.has(bottle3)).to.be.false; 1164 | expect(container2.c.ThrowAway).to.be.undefined; 1165 | 1166 | let idx = 0; 1167 | for (const entity of setInv2.slots) { 1168 | if (idx === 0) { 1169 | expect(entity).to.equal(bottle1); 1170 | } else if (idx === 1) { 1171 | expect(entity).to.equal(bottle2); 1172 | } 1173 | idx++; 1174 | } 1175 | expect(idx).to.equal(2); 1176 | 1177 | expect(setInv2.slots.has(bottle1)).to.be.true; 1178 | bottle1.destroy(); 1179 | expect(setInv2.slots.has(bottle1)).to.be.false; 1180 | expect(setInv2.slots.has(bottle2)).to.be.true; 1181 | setInv2.slots.delete(bottle2.id); 1182 | expect(setInv2.slots.has(bottle2)).to.be.false; 1183 | 1184 | expect(setInv.slots.has(bottle1)).to.be.false; 1185 | expect(setInv.slots.has(bottle2)).to.be.true; 1186 | 1187 | setInv.slots.clear() 1188 | expect(setInv.slots.has(bottle2)).to.be.false; 1189 | 1190 | const bottle4 = ecs.createEntity({ 1191 | c: { 1192 | Bottle: {} 1193 | } 1194 | }); 1195 | 1196 | const bottle5 = ecs.createEntity({ 1197 | c: { 1198 | Bottle: {} 1199 | } 1200 | }); 1201 | 1202 | const withValues = ecs.createEntity({ 1203 | c: { 1204 | SetInventory: { 1205 | slots: [bottle4, bottle5.id] 1206 | } 1207 | } 1208 | }); 1209 | 1210 | expect(withValues.c.SetInventory.slots.has(bottle4)).to.be.true; 1211 | expect(withValues.c.SetInventory.slots.has(bottle5)).to.be.true; 1212 | 1213 | withValues.c.SetInventory.slots._reset(); 1214 | expect(withValues.c.SetInventory.slots.has(bottle4)).to.be.true; 1215 | expect(withValues.c.SetInventory.slots.has(bottle5)).to.be.true; 1216 | 1217 | 1218 | }); 1219 | 1220 | }); 1221 | 1222 | describe('exporting and restoring', () => { 1223 | 1224 | it('get object and stringify component', () => { 1225 | 1226 | const ecs = new ECS.World(); 1227 | class AI extends ECS.Component { 1228 | static properties = { 1229 | order: 'sun' 1230 | }; 1231 | } 1232 | ecs.registerComponent(AI); 1233 | 1234 | const entity = ecs.createEntity({ 1235 | c: { 1236 | moon: { type: 'AI', order: 'moon' }, 1237 | jupiter: { type: 'AI', order: 'jupiter' }, 1238 | } 1239 | }); 1240 | 1241 | const moon = entity.c.moon; 1242 | const obj = moon.getObject(); 1243 | 1244 | expect(obj.type).to.equal('AI'); 1245 | expect(obj.id).to.equal(moon.id); 1246 | }); 1247 | 1248 | it('getObject on entity', () => { 1249 | 1250 | const ecs = new ECS.World(); 1251 | class EquipmentSlot extends ECS.Component { 1252 | static properties = { 1253 | name: 'ring', 1254 | slot: EntityRef 1255 | }; 1256 | } 1257 | class Bottle extends ECS.Component {} 1258 | class AI extends ECS.Component {} 1259 | class Effect extends ECS.Component { 1260 | static properties = { 1261 | name: 'fire' 1262 | }; 1263 | } 1264 | ecs.registerComponent(EquipmentSlot); 1265 | ecs.registerComponent(Bottle); 1266 | ecs.registerComponent(AI); 1267 | ecs.registerComponent(Effect); 1268 | 1269 | const bottle = ecs.createEntity({ 1270 | c: { 1271 | Bottle: {} 1272 | } 1273 | }); 1274 | let npc = ecs.createEntity({ 1275 | c: { 1276 | ring: { type: 'EquipmentSlot', slot: bottle }, 1277 | AI: {} 1278 | } 1279 | }); 1280 | npc.addComponent({ type: 'Effect', name: 'wet' }); 1281 | npc.addComponent({ type: 'Effect', name: 'annoyed' }); 1282 | 1283 | const old = npc.getObject(); 1284 | 1285 | expect(old.c.ring.slot).to.equal(bottle.id); 1286 | 1287 | npc.destroy(); 1288 | npc = undefined; 1289 | 1290 | npc = ecs.createEntity(old); 1291 | 1292 | const old2 = npc.getObject(); 1293 | 1294 | const ring = npc.c.ring; 1295 | expect(ring.slot).to.equal(bottle); 1296 | const effect = npc.getComponents('Effect'); 1297 | expect(effect.size).to.equal(2); 1298 | expect([...effect][0].name).to.equal('wet'); 1299 | expect([...effect][1].name).to.equal('annoyed'); 1300 | }); 1301 | 1302 | it('property skipping', () => { 1303 | 1304 | const ecs = new ECS.World(); 1305 | class Effect extends ECS.Component { 1306 | static properties = { 1307 | name: 'fire', 1308 | started: '' 1309 | }; 1310 | } 1311 | class AI extends ECS.Component { 1312 | static properties = { 1313 | name: 'thingy' 1314 | }; 1315 | } 1316 | ecs.registerComponent(Effect); 1317 | ecs.registerComponent(AI); 1318 | 1319 | class Liquid extends ECS.Component {} 1320 | ecs.registerComponent(Liquid); 1321 | 1322 | const entity = ecs.createEntity({ 1323 | c: { 1324 | Effect: { 1325 | name: 'fire', 1326 | started: Date.now() 1327 | }, 1328 | AI: {}, 1329 | Liquid: {} 1330 | } 1331 | }); 1332 | 1333 | const old = entity.getObject(); 1334 | 1335 | const entity2 = ecs.createEntity(old); 1336 | 1337 | expect(old.AI).to.not.exist; 1338 | //expect(old.Effect.started).to.not.exist; 1339 | expect(old.c.Effect.name).to.equal('fire'); 1340 | expect(old.c.Liquid).to.exist; 1341 | expect(entity2.c.Liquid).to.exist; 1342 | 1343 | expect(entity.getOne(Effect)).to.equal(entity.c.Effect); 1344 | expect(entity.getOne('Effect')).to.equal(entity.c.Effect); 1345 | 1346 | entity2.c.Liquid.key = 'OtherLiquid'; 1347 | expect(entity2.c.Liquid).to.not.exist; 1348 | expect(entity2.c.OtherLiquid).to.exist; 1349 | 1350 | entity2.c.OtherLiquid.key = undefined; 1351 | expect(entity2.c.OtherLiquid).to.not.exist; 1352 | expect(entity2.c.Liquid).to.not.exist; 1353 | }); 1354 | 1355 | }); 1356 | 1357 | describe('advanced queries', () => { 1358 | it('from and reverse queries', () => { 1359 | 1360 | const ecs = new ECS.World(); 1361 | 1362 | ecs.registerTags('A', 'B', 'C', 'D'); 1363 | 1364 | const entity1 = ecs.createEntity({ 1365 | tags: ['A'] 1366 | }); 1367 | const entity2 = ecs.createEntity({ 1368 | tags: ['B'] 1369 | }); 1370 | 1371 | const entity3 = ecs.createEntity({ 1372 | tags: ['B', 'C'] 1373 | }); 1374 | 1375 | const entity4 = ecs.createEntity({ 1376 | tags: ['B', 'C', 'A'] 1377 | }); 1378 | 1379 | const q = ecs.createQuery().from(entity1, entity2.id, entity3); 1380 | const r = q.execute(); 1381 | 1382 | expect(r.has(entity1)).to.be.true; 1383 | expect(r.has(entity2)).to.be.true; 1384 | expect(r.has(entity3)).to.be.true; 1385 | 1386 | const q1b = ecs.createQuery({ from: [entity1, entity2.id, entity3] }); 1387 | const r1b = q.execute(); 1388 | 1389 | expect(r1b.has(entity1)).to.be.true; 1390 | expect(r1b.has(entity2)).to.be.true; 1391 | expect(r1b.has(entity3)).to.be.true; 1392 | 1393 | class Person extends ECS.Component { 1394 | static properties = { 1395 | name: 'Bill' 1396 | }; 1397 | } 1398 | class Item extends ECS.Component { 1399 | static properties = { 1400 | name: 'knife' 1401 | }; 1402 | } 1403 | class InInventory extends ECS.Component { 1404 | static properties = { 1405 | person: EntityRef 1406 | }; 1407 | } 1408 | 1409 | ecs.registerComponent(Person); 1410 | ecs.registerComponent(Item); 1411 | ecs.registerComponent(InInventory); 1412 | 1413 | const e4 = ecs.createEntity({ 1414 | c: { 1415 | Person: { 1416 | name: 'Bob' 1417 | } 1418 | } 1419 | }); 1420 | 1421 | const e5 = ecs.createEntity({ 1422 | c: { 1423 | Item: { 1424 | name: 'plate' 1425 | }, 1426 | InInventory: { 1427 | person: e4 1428 | } 1429 | } 1430 | }); 1431 | 1432 | const q2 = ecs.createQuery({ reverse: { entity: e4, type: 'InInventory'}, persist: true } ); 1433 | const r2 = q2.execute(); 1434 | 1435 | expect(r2.size).to.equal(1); 1436 | expect(r2.has(e5)).to.be.true; 1437 | 1438 | const q2b = ecs.createQuery().fromReverse(e4.id, InInventory).persist(); 1439 | const r2b = q2b.execute(); 1440 | 1441 | expect(r2b.size).to.equal(1); 1442 | expect(r2b.has(e5)).to.be.true; 1443 | 1444 | const q2c = ecs.createQuery().fromAny(Person, 'Item'); 1445 | const r2c = q2c.execute(); 1446 | 1447 | expect(r2c.has(e4)).to.be.true; 1448 | expect(r2c.has(e5)).to.be.true; 1449 | 1450 | const q3 = ecs.createQuery({ any: ['B', 'C'], persist: true }); 1451 | const r3 = q3.execute(); 1452 | 1453 | const q4 = ecs.createQuery({ all: ['B', 'C'], persist: true }); 1454 | const r3b = q4.execute(); 1455 | 1456 | expect(r3.size).to.equal(3); 1457 | expect(r3.has(entity2)).to.be.true; 1458 | expect(r3.has(entity3)).to.be.true; 1459 | expect(r3b.size).to.equal(2); 1460 | expect(r3b.has(entity3)).to.be.true; 1461 | expect(r3b.has(entity4)).to.be.true; 1462 | 1463 | e5.addTag('A'); 1464 | ecs.tick(); 1465 | 1466 | entity2.removeTag('B'); 1467 | e5.removeComponent('InInventory'); 1468 | 1469 | ecs.tick(); 1470 | const r4 = q3.execute(); 1471 | expect(r3.size).to.equal(2); 1472 | expect(r3.has(entity2)).to.be.false; 1473 | expect(r3.has(entity3)).to.be.true; 1474 | 1475 | const q2r2 = q2.execute(); 1476 | expect(q2r2.size).to.equal(0); 1477 | 1478 | const r5 = q4.execute(); 1479 | expect(r5.size).to.equal(2); 1480 | 1481 | expect(r3.has(entity1)).to.be.false; 1482 | entity1.addTag('B'); 1483 | ecs.tick(); 1484 | 1485 | const r6 = q3.execute(); 1486 | expect(r6.has(entity1)).to.be.true; 1487 | const r7 = q2.execute(); 1488 | 1489 | expect(r7.has(e5)).to.be.false; 1490 | 1491 | const entity5 = ecs.createEntity({ 1492 | tags: ['D', 'B', 'A'] 1493 | }); 1494 | 1495 | const q5 = ecs.createQuery().fromAll('A').only('D', 'C', Item) 1496 | const rq5 = q5.execute(); 1497 | 1498 | expect(rq5.has(entity5)).to.be.true; 1499 | 1500 | const q5b = ecs.createQuery({ 1501 | all: ['A'], 1502 | only: ['D', 'C', Item] 1503 | }); 1504 | const rq5b = q5b.execute(); 1505 | 1506 | expect(rq5b.has(entity5)).to.be.true; 1507 | 1508 | }); 1509 | 1510 | it('track added and removed', () => { 1511 | 1512 | const ecs = new ECS.World(); 1513 | class S1 extends ECS.System { 1514 | 1515 | q1: Query; 1516 | 1517 | init() { 1518 | 1519 | this.q1 = this.createQuery({ 1520 | trackAdded: true, 1521 | }).fromAll('A', 'C').persist(); 1522 | } 1523 | 1524 | update(tick) { 1525 | 1526 | const r1 = this.q1.execute(); 1527 | 1528 | switch(tick) { 1529 | case 0: 1530 | expect(r1.has(e5)).to.be.true; 1531 | expect(r1.has(e6)).to.be.false; 1532 | expect(r1.has(e7)).to.be.true; 1533 | expect(r1).not.contains(e1); 1534 | expect(this.q1.added.size).to.be.equal(2); 1535 | expect(this.q1.removed.size).to.be.equal(0); 1536 | expect(this.q1.added.has(e5)).to.be.true; 1537 | expect(this.q1.added.has(e6)).to.be.false; 1538 | expect(this.q1.added.has(e7)).to.be.true; 1539 | break; 1540 | case 1: 1541 | expect(r1).not.contains(e5); 1542 | expect(r1).contains(e1); 1543 | expect(this.q1.removed.size).to.equal(0); 1544 | expect(this.q1.added).contains(e1); 1545 | expect(this.q1.added.size).to.be.equal(1); 1546 | break; 1547 | case 2: 1548 | expect(r1.has(e5)).to.be.false; 1549 | expect(r1.has(e7)).to.be.true; 1550 | expect(r1.has(e1)).to.be.true; 1551 | expect(this.q1.added.has(e1)).to.be.true; 1552 | expect(this.q1.added.size).to.be.equal(1); 1553 | expect(this.q1.removed.size).to.be.equal(0); 1554 | break; 1555 | } 1556 | 1557 | } 1558 | } 1559 | class S2 extends ECS.System {} 1560 | 1561 | const s1 = ecs.registerSystem('group1', S1); 1562 | const s2 = ecs.registerSystem('group2', S2); 1563 | 1564 | ecs.registerTags('A', 'B', 'C'); 1565 | 1566 | var e1 = ecs.createEntity({ 1567 | tags: ['A'] 1568 | }); 1569 | var e2 = ecs.createEntity({ 1570 | tags: ['B'] 1571 | }); 1572 | var e3 = ecs.createEntity({ 1573 | tags: ['C'] 1574 | }); 1575 | var e4 = ecs.createEntity({ 1576 | tags: ['A', 'B'] 1577 | }); 1578 | var e5 = ecs.createEntity({ 1579 | tags: ['A', 'C'] 1580 | }); 1581 | var e6 = ecs.createEntity({ 1582 | tags: ['C', 'B'] 1583 | }); 1584 | var e7 = ecs.createEntity({ 1585 | tags: ['A', 'B', 'C'] 1586 | }); 1587 | 1588 | ecs.runSystems('group1'); 1589 | ecs.tick(); 1590 | 1591 | e5.removeTag('C'); 1592 | e1.addTag('C'); 1593 | 1594 | ecs.runSystems('group1'); 1595 | ecs.tick(); 1596 | 1597 | const q2 = s2.createQuery({ 1598 | trackRemoved: true, 1599 | }).fromAll('A', 'C').persist(); 1600 | 1601 | const r2 = q2.execute(); 1602 | 1603 | expect(r2.has(e1)).to.be.true; 1604 | expect(r2.has(e6)).to.be.false; 1605 | expect(r2.has(e5)).to.be.false; 1606 | expect(r2.has(e7)).to.be.true; 1607 | expect(q2.added.size).to.be.equal(0); 1608 | expect(q2.removed.size).to.be.equal(0); 1609 | 1610 | ecs.tick(); 1611 | 1612 | expect(q2.added.size).to.be.equal(0); 1613 | expect(q2.removed.size).to.be.equal(0); 1614 | 1615 | e7.removeTag('A'); 1616 | e3.addTag('A'); 1617 | 1618 | ecs.tick(); 1619 | 1620 | expect(q2.added.size).to.be.equal(0); 1621 | expect(q2.removed.size).to.be.equal(1); 1622 | expect(r2.has(e3)).to.be.true; 1623 | expect(r2.has(e7)).to.be.false; 1624 | expect(q2.removed.has(e7)).to.be.true; 1625 | 1626 | ecs.runSystems('group1'); 1627 | expect(s1.q1.added.size).to.be.equal(0); 1628 | expect(s1.q1.removed.size).to.be.equal(0); 1629 | 1630 | ecs.runSystems('group2'); 1631 | expect(q2.added.size).to.be.equal(0); 1632 | expect(q2.removed.size).to.be.equal(0); 1633 | 1634 | }); 1635 | }); 1636 | 1637 | describe('serialize and deserialize', () => { 1638 | 1639 | it('maintain refs across worlds', () => { 1640 | 1641 | const worldA = new ECS.World(); 1642 | 1643 | class Inventory extends ECS.Component { 1644 | static properties = { 1645 | main: EntitySet 1646 | }; 1647 | } 1648 | worldA.registerComponent(Inventory); 1649 | 1650 | worldA.registerTags('Bottle', 'Item', 'NPC'); 1651 | 1652 | const npc = worldA.createEntity({ 1653 | id: 'npc1', 1654 | tags: ['NPC'], 1655 | c: { 1656 | Inventory: {} 1657 | } 1658 | }); 1659 | 1660 | const bottle = worldA.createEntity({ 1661 | tags: ['Item', 'Bottle'] 1662 | }); 1663 | 1664 | npc.c.Inventory.main.add(bottle); 1665 | 1666 | const entities1 = worldA.getObject(); 1667 | 1668 | const worldB = new ECS.World(); 1669 | 1670 | class Inventory2 extends ECS.Component { 1671 | static properties = { 1672 | main: EntitySet 1673 | }; 1674 | } 1675 | Object.defineProperty(Inventory2, 'name', { value: 'Inventory' }); 1676 | worldB.registerComponent(Inventory2); 1677 | 1678 | worldB.registerTags('Bottle', 'Item', 'NPC'); 1679 | 1680 | worldB.createEntities(entities1); 1681 | 1682 | const q1 = worldB.createQuery().fromAll('NPC'); 1683 | const r1 = [...q1.execute()]; 1684 | const npc2 = r1[0] 1685 | const bottle2 = [...npc2.c.Inventory.main][0]; 1686 | 1687 | expect(npc.id).to.equal(npc2.id); 1688 | expect(bottle.id).to.equal(bottle2.id); 1689 | expect(bottle2.tags.size).to.equal(2); 1690 | 1691 | const worldC = new ECS.World(); 1692 | 1693 | worldC.copyTypes(worldA, ['Inventory', 'Bottle', 'Item', 'NPC']); 1694 | 1695 | worldC.createEntities(entities1.reverse()); 1696 | 1697 | const npc3 = worldC.entities.get('npc1'); 1698 | const bottle3 = [...npc3.c.Inventory.main][0]; 1699 | 1700 | expect(npc.id).to.equal(npc3.id); 1701 | expect(bottle.id).to.equal(bottle3.id); 1702 | expect(bottle3.tags.size).to.equal(2); 1703 | }); 1704 | 1705 | it('filters serlizable fields', () => { 1706 | 1707 | const world = new ECS.World(); 1708 | class T1 extends ECS.Component { 1709 | static properties = { 1710 | a: 1, 1711 | b: 2, 1712 | c: 3, 1713 | d: 'd', 1714 | e: 4 1715 | }; 1716 | static serializeFields = ['a', 'b', 'd', 'e']; 1717 | } 1718 | class T2 extends ECS.Component { 1719 | static properties = { 1720 | a: 1, 1721 | b: 2, 1722 | c: 3, 1723 | d: 'd', 1724 | e: 4 1725 | }; 1726 | static serializeFields = ['a', 'b', 'd', 'e']; 1727 | static skipSerializeFields = ['a', 'e']; 1728 | } 1729 | world.registerComponent(T1); 1730 | world.registerComponent(T2); 1731 | const entity = world.createEntity({ 1732 | components: [ 1733 | { 1734 | type: 'T1', 1735 | key: 'T1' 1736 | }, 1737 | { 1738 | type: 'T2', 1739 | key: 'T2' 1740 | } 1741 | ] 1742 | }); 1743 | 1744 | const obj = entity.getObject(); 1745 | 1746 | expect(obj.c.T1).to.haveOwnProperty('a'); 1747 | expect(obj.c.T1).to.haveOwnProperty('b'); 1748 | expect(obj.c.T1).to.not.haveOwnProperty('c'); 1749 | expect(obj.c.T1).to.haveOwnProperty('d'); 1750 | expect(obj.c.T1).to.haveOwnProperty('e'); 1751 | 1752 | expect(obj.c.T2).to.not.haveOwnProperty('a'); 1753 | expect(obj.c.T2).to.haveOwnProperty('b'); 1754 | expect(obj.c.T2).to.not.haveOwnProperty('c'); 1755 | expect(obj.c.T2).to.haveOwnProperty('d'); 1756 | expect(obj.c.T2).to.not.haveOwnProperty('e'); 1757 | }); 1758 | }); 1759 | 1760 | describe('pool stats', () => { 1761 | it('logs output', () => { 1762 | 1763 | const ecs = new ECS.World({ 1764 | entityPool: 10 1765 | }); 1766 | 1767 | const logs = []; 1768 | function logStats(output) { 1769 | logs.push(output); 1770 | } 1771 | 1772 | class Test extends Component {}; 1773 | ecs.registerComponent(Test, 50); 1774 | ecs.logStats(2, logStats); 1775 | 1776 | for (let i = 0; i < 1000; i++) { 1777 | ecs.createEntity({ 1778 | components: [{type: 'Test'}] 1779 | }); 1780 | } 1781 | 1782 | const stats1 = ecs.getStats(); 1783 | const stest1 = stats1.components.Test; 1784 | expect(stats1.entity.target).to.equal(10); 1785 | expect(stats1.entity.pooled).to.equal(0); 1786 | expect(stats1.entity.active).to.equal(1000); 1787 | expect(stest1.target).to.equal(50); 1788 | expect(stest1.pooled).to.equal(0); 1789 | expect(stest1.active).to.equal(1000); 1790 | 1791 | ecs.tick(); 1792 | 1793 | const entities = ecs.getEntities(Test); 1794 | for (const entity of entities) { 1795 | entity.destroy(); 1796 | } 1797 | 1798 | for (let i = 0; i < 14; i++) { 1799 | ecs.tick(); 1800 | } 1801 | 1802 | expect(logs.length).to.equal(7); 1803 | const stats2 = ecs.getStats(); 1804 | const stest2 = stats2.components.Test; 1805 | expect(stats2.entity.target).to.equal(10); 1806 | expect(stats2.entity.pooled).to.be.above(9); 1807 | expect(stats2.entity.pooled).to.be.below(100); 1808 | expect(stats2.entity.active).to.equal(0); 1809 | expect(stest2.target).to.equal(50); 1810 | expect(stest2.pooled).to.be.above(49); 1811 | expect(stest2.pooled).to.be.below(101); 1812 | expect(stest2.active).to.equal(0); 1813 | }); 1814 | }); 1815 | 1816 | describe('ApeDestroy', () => { 1817 | it('Test ApeDestroy Queries', () => { 1818 | 1819 | const ecs = new World({ 1820 | useApeDestroy: true 1821 | }); 1822 | 1823 | class Test extends Component {} 1824 | 1825 | ecs.registerTags('A', 'B', 'C'); 1826 | ecs.registerComponent(Test, 10); 1827 | 1828 | const e1 = ecs.createEntity({ 1829 | tags: ['A'], 1830 | components: [{ 1831 | type: 'Test' 1832 | }] 1833 | }); 1834 | 1835 | const q1 = ecs.createQuery().fromAll('Test', 'A'); 1836 | const r1 = q1.execute(); 1837 | 1838 | expect(r1).contains(e1); 1839 | 1840 | const q1b = ecs.createQuery({ all: ['Test', 'A']}); 1841 | const r1b = q1b.execute(); 1842 | 1843 | expect(r1b).contains(e1); 1844 | 1845 | e1.addTag('ApeDestroy'); 1846 | 1847 | const q2 = ecs.createQuery({ all: ['Test', 'A'], not: ['B'] }); 1848 | const r2 = q2.execute(); 1849 | 1850 | expect(r2).not.contains(e1); 1851 | 1852 | const q3 = ecs.createQuery({ includeApeDestroy: true, not: ['B'] }).fromAll('Test', 'A'); 1853 | const r3 = q3.execute(); 1854 | 1855 | expect(r3).contains(e1); 1856 | 1857 | ecs.tick(); 1858 | 1859 | q3.refresh(); 1860 | const r3b = q3.execute(); 1861 | expect(r3b).not.contains(e1); 1862 | }); 1863 | }); 1864 | 1865 | describe('Component Portability', () => { 1866 | it('Components on multiple worlds', () => { 1867 | const world1 = new World(); 1868 | const world2 = new World(); 1869 | 1870 | class Testa extends Component { 1871 | 1872 | static properties = { 1873 | greeting: "Hi", 1874 | a: 1 1875 | }; 1876 | 1877 | static typeName = 'Test'; 1878 | 1879 | greeting: string; 1880 | a: number; 1881 | 1882 | } 1883 | 1884 | world1.registerComponent(Testa); 1885 | world2.registerComponent(Testa); 1886 | 1887 | const t1 = world1.createEntity({ 1888 | c: { 1889 | Test: {} 1890 | } 1891 | }); 1892 | 1893 | const t2 = world2.createEntity({ 1894 | c: { 1895 | Test: {} 1896 | } 1897 | }); 1898 | 1899 | const q1 = world1.createQuery().fromAll('Test'); 1900 | const q2 = world2.createQuery().fromAll('Test'); 1901 | 1902 | t1.c.Test.greeting = "Hello"; 1903 | t2.c.Test.greeting = "Howdy"; 1904 | 1905 | const r1 = q1.execute(); 1906 | const r2 = q2.execute(); 1907 | expect(r1.size).is.equal(1); 1908 | expect(r2.size).is.equal(1); 1909 | expect(r1).contains(t1); 1910 | expect(r2).contains(t2); 1911 | expect(t1.c.Test.greeting).is.equal('Hello'); 1912 | 1913 | }); 1914 | }); 1915 | 1916 | describe('Regressions', () => { 1917 | 1918 | it('#66 Calling destroy twice has very odd effects', () => { 1919 | 1920 | const world = new World({ entityPool: 1 }); 1921 | class TestA extends Component { 1922 | 1923 | static properties = { 1924 | greeting: "Hi", 1925 | a: 1 1926 | }; 1927 | 1928 | static typeName = 'TestA'; 1929 | greeting: string; 1930 | a: number; 1931 | } 1932 | 1933 | class TestB extends Component { 1934 | 1935 | static properties = { 1936 | greeting: "Hi", 1937 | a: 1 1938 | }; 1939 | 1940 | static typeName = 'TestB'; 1941 | greeting: string; 1942 | a: number; 1943 | } 1944 | 1945 | world.registerComponent(TestA); 1946 | world.registerComponent(TestB); 1947 | 1948 | const e = world.createEntity({ 1949 | c: { 1950 | TestA: { 1951 | greeting: "What", 1952 | a: 2 1953 | } 1954 | } 1955 | }); 1956 | world.removeEntity(e); 1957 | e.destroy(); 1958 | 1959 | const e2 = world.createEntity({ 1960 | c: { 1961 | TestB: { 1962 | greeting: "No", 1963 | a: 3 1964 | } 1965 | } 1966 | }); 1967 | 1968 | expect(e2.has('TestA')).to.be.false; 1969 | expect(e2.has('TestB')).to.be.true; 1970 | }); 1971 | }); 1972 | --------------------------------------------------------------------------------