├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── package.json ├── packages ├── hecs-bench │ ├── package.json │ ├── src │ │ ├── ComponentStorages.bench.ts │ │ ├── System.bench.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── tsconfig.web.json │ └── webpack.config.js ├── hecs-example │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ ├── tsconfig.web.json │ └── webpack.config.js ├── hecs │ ├── README.md │ ├── package.json │ ├── src │ │ ├── FlagComponentStorage.ts │ │ ├── MapComponentStorage.ts │ │ ├── SparseArrayComponentStorage.ts │ │ ├── System.ts │ │ └── index.ts │ ├── test │ │ ├── ComponentStorages.test.ts │ │ ├── System.test.ts │ │ └── World.test.ts │ ├── tsconfig.cjs.json │ ├── tsconfig.esm.json │ └── tsconfig.json ├── tsconfig.base.json └── webpack.base.config.js ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output/ 3 | coverage/ 4 | lib-cjs/ 5 | lib-esm/ 6 | dist/ 7 | yarn-error.log 8 | .DS_Store -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Run AVA test", 11 | "program": "${workspaceFolder}/node_modules/ava/profile.js", 12 | "args": [ 13 | "${file}" 14 | ], 15 | "skipFiles": [ 16 | "/**/*.js" 17 | ] 18 | }, 19 | { 20 | "type": "node", 21 | "request": "launch", 22 | "name": "Run AVA test serially", 23 | "program": "${workspaceFolder}/node_modules/ava/profile.js", 24 | "args": [ 25 | "${file}", 26 | "--serial" 27 | ], 28 | "skipFiles": [ 29 | "/**/*.js" 30 | ] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright © 2019 hecs authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HECS 2 | 3 | > HECS Entity Component System 4 | 5 | An experimental ECS written in Javascript. 6 | 7 | ## THIS PROJECT IS NO LONGER MAINTAINED 8 | 9 | I have transfered the npm package to a new project. The last maintained version of hecs is `0.1.1`. I am currently maintaining a new ECS project [ECSY](https://ecsy.io) and encourage users to move to it. 10 | 11 | ## Goals 12 | 13 | - Performance 14 | - As little memory allocation and garbage collection as possible 15 | - Fast iterators 16 | - Predictability 17 | - Systems run in the order they are registered 18 | - Events are processed in the game loop with the rest of the system's logic 19 | 20 | ## Roadmap 21 | 22 | - Custom Schedulers 23 | - Specify dependencies between systems 24 | - Enable systems to run in parallel 25 | - Parallelism 26 | - Using transferable objects in WebWorkers 27 | - Using SharedArrayBuffer 28 | - WASM Integration 29 | - Rust API that can be used to write high performance systems in WASM 30 | 31 | ## Getting Started 32 | 33 | ``` 34 | npm install -S hecs@0.1.1 35 | ``` 36 | 37 | ```js 38 | import { World, System, EntityId, Read, Write } from "hecs"; 39 | 40 | const world = new World(); 41 | 42 | class Position { 43 | constructor(x) { 44 | this.x = x; 45 | } 46 | } 47 | 48 | class Velocity { 49 | constructor(v) { 50 | this.v = v; 51 | } 52 | } 53 | 54 | world.registerComponent(Position); 55 | world.registerComponent(Velocity); 56 | 57 | class VelocitySystem extends System { 58 | setup() { 59 | return { 60 | entities: this.world.createQuery(Write(Position), Read(Velocity)) 61 | }; 62 | } 63 | 64 | update() { 65 | for (const [position, velocity] of this.ctx.entities) { 66 | position.x += velocity.v; 67 | } 68 | } 69 | } 70 | 71 | class LoggingSystem extends System { 72 | setup() { 73 | return { 74 | entities: this.world.createQuery(EntityId, Read(Position)) 75 | }; 76 | } 77 | 78 | update() { 79 | for (const [entityId, position] of this.ctx.entities) { 80 | console.log(`Entity ${entityId} has position: ${position.x}`); 81 | } 82 | } 83 | } 84 | 85 | world.registerSystem(new VelocitySystem()); 86 | world.registerSystem(new LoggingSystem()); 87 | 88 | const entity1 = world.createEntity(); 89 | world.addComponent(entity1, new Position(0)); 90 | world.addComponent(entity1, new Velocity(1)); 91 | 92 | const entity2 = world.createEntity(); 93 | world.addComponent(entity2, new Position(10)); 94 | world.addComponent(entity2, new Velocity(0.5)); 95 | 96 | function update() { 97 | world.update(); 98 | requestAnimationFrame(update); 99 | } 100 | 101 | requestAnimationFrame(update); 102 | ``` 103 | 104 | ## Credits 105 | 106 | - API heavily inspired by [Specs](https://github.com/slide-rs/specs) 107 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hecs-workspace", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "start": "wsrun --package hecs-example -c start", 9 | "build": "wsrun --exclude-missing build", 10 | "test": "wsrun --exclude-missing test", 11 | "bench": "wsrun --exclude-missing bench", 12 | "lint": "tslint packages/**/*.ts", 13 | "postinstall": "yarn build", 14 | "precommit": "lint-staged" 15 | }, 16 | "devDependencies": { 17 | "husky": "^1.3.1", 18 | "lint-staged": "^8.1.4", 19 | "prettier": "^1.16.4", 20 | "tslint": "^5.12.1", 21 | "tslint-config-prettier": "^1.18.0", 22 | "tslint-plugin-prettier": "^2.0.1", 23 | "typescript": "^3.3.1", 24 | "wsrun": "^3.6.4" 25 | }, 26 | "lint-staged": { 27 | "*.ts": [ 28 | "yarn lint --", 29 | "git add" 30 | ] 31 | }, 32 | "dependencies": {} 33 | } 34 | -------------------------------------------------------------------------------- /packages/hecs-bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hecs-bench", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "Benchmarks for hecs", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "bench": "npm run bench:node", 9 | "bench:node": "cross-env NODE_ENV=production ts-node ./src/index.ts", 10 | "bench:browser": "cross-env TS_NODE_PROJECT=tsconfig.web.json NODE_ENV=production webpack-dev-server" 11 | }, 12 | "author": "Robert Long", 13 | "license": "MIT", 14 | "dependencies": { 15 | "hecs": "^0.1.0" 16 | }, 17 | "devDependencies": { 18 | "cross-env": "^5.2.0", 19 | "html-webpack-plugin": "^3.2.0", 20 | "ts-loader": "^5.3.3", 21 | "ts-node": "^8.0.2", 22 | "typescript": "^3.3.3333", 23 | "webpack": "^4.29.5", 24 | "webpack-cli": "^3.2.3", 25 | "webpack-dev-server": "^3.2.0", 26 | "webpack-merge": "^4.2.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/hecs-bench/src/ComponentStorages.bench.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: no-console 2 | import { MapComponentStorage, SparseArrayComponentStorage } from "hecs"; 3 | 4 | const mapStorage = new MapComponentStorage(); 5 | const arrayStorage = new SparseArrayComponentStorage(); 6 | const randMapStorage = new MapComponentStorage(); 7 | const randArrayStorage = new SparseArrayComponentStorage(); 8 | 9 | class TransformComponent {} 10 | 11 | const transformComponents = []; 12 | 13 | for (let i = 0; i < 1000000; i++) { 14 | transformComponents.push(new TransformComponent()); 15 | } 16 | 17 | console.time("MapComponentStorage#set()"); 18 | 19 | for (let i = 0; i < 1000000; i++) { 20 | mapStorage.set(i, transformComponents[i]); 21 | } 22 | 23 | console.timeEnd("MapComponentStorage#set()"); 24 | 25 | console.time("SparseArrayComponentStorage#set()"); 26 | 27 | for (let i = 0; i < 1000000; i++) { 28 | arrayStorage.set(i, transformComponents[i]); 29 | } 30 | 31 | console.timeEnd("SparseArrayComponentStorage#set()"); 32 | 33 | console.time("MapComponentStorage#get() linear"); 34 | 35 | for (let i = 0; i < 1000000; i++) { 36 | mapStorage.get(i); 37 | } 38 | 39 | console.timeEnd("MapComponentStorage#get() linear"); 40 | 41 | console.time("SparseArrayComponentStorage#get() linear"); 42 | 43 | for (let i = 0; i < 1000000; i++) { 44 | arrayStorage.get(i); 45 | } 46 | 47 | console.timeEnd("SparseArrayComponentStorage#get() linear"); 48 | 49 | console.time("MapComponentStorage#get() random"); 50 | 51 | for (let i = 0; i < 1000000; i++) { 52 | mapStorage.get(Math.random() * 1000000); 53 | } 54 | 55 | console.timeEnd("MapComponentStorage#get() random"); 56 | 57 | console.time("SparseArrayComponentStorage#get() random"); 58 | 59 | for (let i = 0; i < 1000000; i++) { 60 | arrayStorage.get(Math.random() * 1000000); 61 | } 62 | 63 | console.timeEnd("SparseArrayComponentStorage#get() random"); 64 | 65 | console.time("MapComponentStorage#remove() linear"); 66 | 67 | for (let i = 0; i < 1000000; i++) { 68 | mapStorage.remove(i); 69 | } 70 | 71 | console.timeEnd("MapComponentStorage#remove() linear"); 72 | 73 | console.time("SparseArrayComponentStorage#remove() linear"); 74 | 75 | for (let i = 0; i < 1000000; i++) { 76 | arrayStorage.remove(i); 77 | } 78 | 79 | console.timeEnd("SparseArrayComponentStorage#remove() linear"); 80 | 81 | for (let i = 0; i < 1000000; i++) { 82 | randMapStorage.set(i, transformComponents[i]); 83 | randArrayStorage.set(i, transformComponents[i]); 84 | } 85 | 86 | console.time("MapComponentStorage#remove() random"); 87 | 88 | for (let i = 0; i < 1000000; i++) { 89 | mapStorage.remove(Math.random() * 1000000); 90 | } 91 | 92 | console.timeEnd("MapComponentStorage#remove() random"); 93 | 94 | console.time("SparseArrayComponentStorage#remove() random"); 95 | 96 | for (let i = 0; i < 1000000; i++) { 97 | arrayStorage.remove(Math.random() * 1000000); 98 | } 99 | 100 | console.timeEnd("SparseArrayComponentStorage#remove() random"); 101 | -------------------------------------------------------------------------------- /packages/hecs-bench/src/System.bench.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: no-console 2 | 3 | import { 4 | ComponentEvent, 5 | IComponent, 6 | IEventChannel, 7 | IQuery, 8 | ISystem, 9 | ISystemContext, 10 | SparseArrayComponentStorage, 11 | System, 12 | World 13 | } from "hecs"; 14 | 15 | class TestComponent implements IComponent {} 16 | 17 | interface IBenchSystemContext extends ISystemContext { 18 | entities: IQuery<[TestComponent]>; 19 | } 20 | 21 | class BenchSystem extends System { 22 | public setup() { 23 | return { 24 | entities: this.world.createQuery(TestComponent) 25 | }; 26 | } 27 | 28 | public update() { 29 | // tslint:disable-next-line:no-empty 30 | for (const [component] of this.ctx.entities) { 31 | } 32 | } 33 | } 34 | 35 | interface IEventChannelSystemContext extends ISystemContext { 36 | events: IEventChannel; 37 | } 38 | 39 | class EventChannelSystem extends System { 40 | public setup() { 41 | return { 42 | events: this.world.createEventChannel(ComponentEvent.Added, TestComponent) 43 | }; 44 | } 45 | 46 | public update() { 47 | // tslint:disable-next-line:no-empty 48 | for (const [entityId, component] of this.ctx.events) { 49 | } 50 | } 51 | } 52 | 53 | function waitFor(time: number) { 54 | return new Promise(resolve => setTimeout(resolve, time)); 55 | } 56 | 57 | async function benchmarkSystem( 58 | system: ISystem, 59 | numEntities = 1000, 60 | numUpdates = 1, 61 | numSystems = 1 62 | ) { 63 | const world = new World(); 64 | world.registerComponent(TestComponent, new SparseArrayComponentStorage()); 65 | 66 | for (let i = 0; i < numSystems; i++) { 67 | world.registerSystem(system); 68 | } 69 | 70 | for (let i = 0; i < numEntities; i++) { 71 | const id = world.createEntity(); 72 | world.addComponent(id, new TestComponent()); 73 | } 74 | 75 | const message = `Run ${numSystems} ${ 76 | system.constructor.name 77 | } ${numUpdates} times with ${numEntities} entities.`; 78 | 79 | console.time(message); 80 | 81 | for (let i = 0; i < numUpdates; i++) { 82 | world.update(); 83 | } 84 | 85 | console.timeEnd(message); 86 | 87 | await waitFor(1000); 88 | } 89 | 90 | async function main() { 91 | await waitFor(1000); 92 | await benchmarkSystem(new BenchSystem()); 93 | await benchmarkSystem(new BenchSystem(), 1000, 1, 30); 94 | await benchmarkSystem(new BenchSystem(), 10000, 1, 30); 95 | await benchmarkSystem(new BenchSystem(), 10000, 10, 30); 96 | await benchmarkSystem(new EventChannelSystem()); 97 | await benchmarkSystem(new EventChannelSystem(), 1000, 1, 30); 98 | await benchmarkSystem(new EventChannelSystem(), 10000, 1, 30); 99 | await benchmarkSystem(new EventChannelSystem(), 10000, 10, 30); 100 | } 101 | 102 | main() 103 | .then(() => { 104 | if (typeof document !== "undefined") { 105 | document.body.innerText = "Finished."; 106 | } 107 | }) 108 | .catch(console.error); 109 | -------------------------------------------------------------------------------- /packages/hecs-bench/src/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-var-requires 2 | if (typeof document !== "undefined") { 3 | document.body.innerText = "Open your developer console for results."; 4 | } 5 | 6 | require("./ComponentStorages.bench"); 7 | require("./System.bench"); 8 | -------------------------------------------------------------------------------- /packages/hecs-bench/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["./src/**/*.ts"] 7 | } -------------------------------------------------------------------------------- /packages/hecs-bench/tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false 5 | } 6 | } -------------------------------------------------------------------------------- /packages/hecs-bench/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const merge = require("webpack-merge"); 3 | const baseConfig = require("../webpack.base.config"); 4 | 5 | module.exports = merge(baseConfig, { 6 | entry: path.resolve(__dirname, "src", "index.ts"), 7 | output: { 8 | path: path.resolve(__dirname, "dist"), 9 | filename: "bundle.js" 10 | } 11 | }); -------------------------------------------------------------------------------- /packages/hecs-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hecs-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "Example application for hecs", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "cross-env TS_NODE_PROJECT=tsconfig.web.json NODE_ENV=development webpack-dev-server" 9 | }, 10 | "author": "Robert Long", 11 | "license": "MIT", 12 | "dependencies": { 13 | "hecs": "^0.1.0" 14 | }, 15 | "devDependencies": { 16 | "cross-env": "^5.2.0", 17 | "html-webpack-plugin": "^3.2.0", 18 | "ts-loader": "^5.3.3", 19 | "typescript": "^3.3.3333", 20 | "webpack": "^4.29.5", 21 | "webpack-cli": "^3.2.3", 22 | "webpack-dev-server": "^3.2.0", 23 | "webpack-merge": "^4.2.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/hecs-example/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EntityId, 3 | IQuery, 4 | ISystemContext, 5 | Read, 6 | System, 7 | TEntityId, 8 | World, 9 | Write 10 | } from "hecs"; 11 | 12 | const world = new World(); 13 | 14 | class Position { 15 | public x: number; 16 | 17 | constructor(x: number) { 18 | this.x = x; 19 | } 20 | } 21 | 22 | class Velocity { 23 | public v: number; 24 | 25 | constructor(v: number) { 26 | this.v = v; 27 | } 28 | } 29 | 30 | world.registerComponent(Position); 31 | world.registerComponent(Velocity); 32 | 33 | interface IVelocitySystemContext extends ISystemContext { 34 | entities: IQuery<[Position, Velocity]>; 35 | } 36 | 37 | class VelocitySystem extends System { 38 | public setup() { 39 | return { 40 | entities: this.world.createQuery(Write(Position), Read(Velocity)) 41 | }; 42 | } 43 | 44 | public update() { 45 | for (const [position, velocity] of this.ctx.entities) { 46 | position.x += velocity.v; 47 | } 48 | } 49 | } 50 | 51 | interface ILoggingSystemContext extends ISystemContext { 52 | entities: IQuery<[TEntityId, Position]>; 53 | } 54 | 55 | class LoggingSystem extends System { 56 | public setup() { 57 | return { 58 | entities: this.world.createQuery(EntityId, Read(Position)) 59 | }; 60 | } 61 | 62 | public update() { 63 | for (const [entityId, position] of this.ctx.entities) { 64 | // tslint:disable-next-line: no-console 65 | console.log(`Entity ${entityId} has position: ${position.x}`); 66 | } 67 | } 68 | } 69 | 70 | world.registerSystem(new VelocitySystem()); 71 | world.registerSystem(new LoggingSystem()); 72 | 73 | const entity1 = world.createEntity(); 74 | world.addComponent(entity1, new Position(0)); 75 | world.addComponent(entity1, new Velocity(1)); 76 | 77 | const entity2 = world.createEntity(); 78 | world.addComponent(entity2, new Position(10)); 79 | world.addComponent(entity2, new Velocity(0.5)); 80 | 81 | function update() { 82 | world.update(); 83 | requestAnimationFrame(update); 84 | } 85 | 86 | requestAnimationFrame(update); 87 | -------------------------------------------------------------------------------- /packages/hecs-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": ["./src/**/*.ts"] 7 | } -------------------------------------------------------------------------------- /packages/hecs-example/tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false 5 | } 6 | } -------------------------------------------------------------------------------- /packages/hecs-example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const merge = require("webpack-merge"); 3 | const baseConfig = require("../webpack.base.config"); 4 | 5 | module.exports = merge(baseConfig, { 6 | entry: path.resolve(__dirname, "src", "index.ts"), 7 | output: { 8 | path: path.resolve(__dirname, "dist"), 9 | filename: "bundle.js" 10 | } 11 | }); -------------------------------------------------------------------------------- /packages/hecs/README.md: -------------------------------------------------------------------------------- 1 | # HECS 2 | 3 | > HECS Entity Component System 4 | 5 | An experimental ECS written in Javascript. 6 | 7 | ## Goals 8 | 9 | - Performance 10 | - As little memory allocation and garbage collection as possible 11 | - Fast iterators 12 | - Predictability 13 | - Systems run in the order they are registered 14 | - Events are processed in the game loop with the rest of the system's logic 15 | 16 | ## Roadmap 17 | 18 | - Custom Schedulers 19 | - Specify dependencies between systems 20 | - Enable systems to run in parallel 21 | - Parallelism 22 | - Using transferable objects in WebWorkers 23 | - Using SharedArrayBuffer 24 | - WASM Integration 25 | - Rust API that can be used to write high performance systems in WASM 26 | 27 | ## Getting Started 28 | 29 | ``` 30 | npm install -S hecs 31 | ``` 32 | 33 | ```js 34 | import { World, System, EntityId, Read, Write } from "hecs"; 35 | 36 | const world = new World(); 37 | 38 | class Position { 39 | constructor(x) { 40 | this.x = x; 41 | } 42 | } 43 | 44 | class Velocity { 45 | constructor(v) { 46 | this.v = v; 47 | } 48 | } 49 | 50 | world.registerComponent(Position); 51 | world.registerComponent(Velocity); 52 | 53 | class VelocitySystem extends System { 54 | setup() { 55 | return { 56 | entities: this.world.createQuery(Write(Position), Read(Velocity)) 57 | }; 58 | } 59 | 60 | update() { 61 | for (const [position, velocity] of this.ctx.entities) { 62 | position.x += velocity.v; 63 | } 64 | } 65 | } 66 | 67 | class LoggingSystem extends System { 68 | setup() { 69 | return { 70 | entities: this.world.createQuery(EntityId, Read(Position)) 71 | }; 72 | } 73 | 74 | update() { 75 | for (const [entityId, position] of this.ctx.entities) { 76 | console.log(`Entity ${entityId} has position: ${position.x}`); 77 | } 78 | } 79 | } 80 | 81 | world.registerSystem(new VelocitySystem()); 82 | world.registerSystem(new LoggingSystem()); 83 | 84 | const entity1 = world.createEntity(); 85 | world.addComponent(entity1, new Position(0)); 86 | world.addComponent(entity1, new Velocity(1)); 87 | 88 | const entity2 = world.createEntity(); 89 | world.addComponent(entity2, new Position(10)); 90 | world.addComponent(entity2, new Velocity(0.5)); 91 | 92 | function update() { 93 | world.update(); 94 | requestAnimationFrame(update); 95 | } 96 | 97 | requestAnimationFrame(update); 98 | ``` 99 | 100 | ## Credits 101 | 102 | - API heavily inspired by [Specs](https://github.com/slide-rs/specs) 103 | -------------------------------------------------------------------------------- /packages/hecs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hecs", 3 | "version": "0.1.0", 4 | "description": "An experimental ECS written in Javascript.", 5 | "author": "Robert Long", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/robertlong/hecs" 10 | }, 11 | "main": "lib-cjs/index.js", 12 | "module": "lib-esm/index.js", 13 | "types": "lib-cjs/index.d.ts", 14 | "files": [ 15 | "package.json", 16 | "LICENSE", 17 | "README.md", 18 | "lib-esm/", 19 | "lib-cjs/" 20 | ], 21 | "scripts": { 22 | "test": "cross-env NODE_ENV=development ava", 23 | "test:watch": "cross-env NODE_ENV=development ava --watch", 24 | "coverage": "cross-env NODE_ENV=development nyc ava", 25 | "build": "npm run build:commonjs && npm run build:esm", 26 | "build:commonjs": "rimraf lib-cjs && tsc -p ./tsconfig.cjs.json", 27 | "build:esm": "rimraf lib-esm && tsc -p ./tsconfig.esm.json" 28 | }, 29 | "ava": { 30 | "compileEnhancements": false, 31 | "extensions": [ 32 | "ts" 33 | ], 34 | "require": [ 35 | "ts-node/register" 36 | ], 37 | "files": [ 38 | "test/**/*.test.ts" 39 | ], 40 | "sources": [ 41 | "src/**/*.ts" 42 | ] 43 | }, 44 | "nyc": { 45 | "include": [ 46 | "src/**/*.ts" 47 | ], 48 | "extension": [ 49 | ".ts" 50 | ], 51 | "require": [ 52 | "ts-node/register" 53 | ], 54 | "reporter": [ 55 | "json", 56 | "html" 57 | ], 58 | "all": true 59 | }, 60 | "devDependencies": { 61 | "@types/node": "^11.9.4", 62 | "ava": "^1.2.1", 63 | "cross-env": "^5.2.0", 64 | "nyc": "^13.3.0", 65 | "rimraf": "^2.6.3", 66 | "ts-node": "^8.0.2", 67 | "typescript": "^3.3.3333" 68 | } 69 | } -------------------------------------------------------------------------------- /packages/hecs/src/FlagComponentStorage.ts: -------------------------------------------------------------------------------- 1 | import { IComponent, IComponentStorage, TEntityId } from "./index"; 2 | 3 | /** 4 | * A ComponentStorage that stores no component data. It is only used for checking whether or not an entity has this component. 5 | */ 6 | export class FlagComponentStorage 7 | implements IComponentStorage { 8 | public get(entityId: TEntityId): T { 9 | return undefined; 10 | } 11 | 12 | public set(entityId: TEntityId, component: T): T { 13 | return undefined; 14 | } 15 | 16 | public remove(entityId: TEntityId) { 17 | return true; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/hecs/src/MapComponentStorage.ts: -------------------------------------------------------------------------------- 1 | import { IComponent, IComponentStorage, TEntityId } from "./index"; 2 | 3 | /** 4 | * A ComponentStorage that is good for storing components that do not exist on all entities. 5 | */ 6 | export class MapComponentStorage 7 | implements IComponentStorage { 8 | private components: Map = new Map(); 9 | 10 | public get(entityId: TEntityId) { 11 | return this.components.get(entityId); 12 | } 13 | 14 | public set(entityId: TEntityId, component: T) { 15 | this.components.set(entityId, component); 16 | return component; 17 | } 18 | 19 | public remove(entityId: TEntityId) { 20 | return this.components.delete(entityId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/hecs/src/SparseArrayComponentStorage.ts: -------------------------------------------------------------------------------- 1 | import { IComponent, IComponentStorage, TEntityId } from "./index"; 2 | 3 | /** 4 | * A ComponentStorage that is good for storing components that are on almost all entities. 5 | */ 6 | export class SparseArrayComponentStorage 7 | implements IComponentStorage { 8 | private components: T[] = []; 9 | 10 | public get(entityId: TEntityId): T { 11 | return this.components[entityId]; 12 | } 13 | 14 | public set(entityId: TEntityId, component: T): T { 15 | const components = this.components; 16 | 17 | for (let i = components.length; i <= entityId; i++) { 18 | components.push(undefined); 19 | } 20 | 21 | return (components[entityId] = component); 22 | } 23 | 24 | public remove(entityId: TEntityId): boolean { 25 | const components = this.components; 26 | const component = components[entityId]; 27 | components[entityId] = undefined; 28 | return component !== undefined; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/hecs/src/System.ts: -------------------------------------------------------------------------------- 1 | import { IComponent, IEventChannel, IQuery, ISystem, World } from "./index"; 2 | 3 | export interface ISystemContext { 4 | [name: string]: IEventChannel | IQuery; 5 | } 6 | 7 | /** 8 | * The default System class that automatically destroys its query objects. 9 | */ 10 | export abstract class System implements ISystem { 11 | public world: World; 12 | public ctx: T; 13 | 14 | public init(world: World) { 15 | this.world = world; 16 | this.ctx = this.setup(); 17 | } 18 | 19 | public abstract setup(): T; 20 | 21 | public abstract update(): void; 22 | 23 | public destroy() { 24 | for (const key in this.ctx) { 25 | if (this.ctx.hasOwnProperty(key)) { 26 | this.ctx[key].destroy(); 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/hecs/src/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: no-bitwise 2 | import { MapComponentStorage } from "./MapComponentStorage"; 3 | 4 | let wrapImmutableComponent: (component: T) => T; 5 | if (process.env.NODE_ENV === "development") { 6 | const proxyHandler = { 7 | set(target: IComponent, prop: string) { 8 | throw new Error( 9 | `Tried to write to "${target.constructor.name}#${String( 10 | prop 11 | )}" on immutable component. Use Write() or .getMutableComponent() to write to a component.` 12 | ); 13 | } 14 | }; 15 | 16 | const proxyMap: WeakMap = new WeakMap(); 17 | 18 | wrapImmutableComponent = (component: T): T => { 19 | if (component === undefined) { 20 | return undefined; 21 | } 22 | 23 | let wrappedComponent = proxyMap.get(component); 24 | 25 | if (!wrappedComponent) { 26 | wrappedComponent = new Proxy( 27 | (component as unknown) as object, 28 | proxyHandler 29 | ) as T; 30 | proxyMap.set(component, wrappedComponent); 31 | } 32 | 33 | return wrappedComponent as T; 34 | }; 35 | } 36 | 37 | export class World { 38 | protected entityPool: TEntityId[]; 39 | protected entityCount: TEntityId; 40 | /** 41 | * Bitset for each entity can be stored in a single typed array / array buffer 42 | * for the best data locality when iterating through entities. Data for each entity 43 | * is stored in one or more 32 bit chunks for fast iteration per entity. 44 | * 45 | * Uint32Array: | 0 | 1 | 2 | ... | 32 | ... 46 | * | Entity 0 | Component.id 0 | Component.id 1 | ... | Entity 1 | ... 47 | * Entity Flag: 0 = Inactive, 1 = Active 48 | * Component Flag: 0 = Inactive, 1 = Active 49 | */ 50 | protected entityFlags: Uint32Array; 51 | protected entityMaskLength: number; 52 | protected componentConstructors: Array>; 53 | protected componentStorages: Array>; 54 | protected componentEventQueues: { 55 | [componentId: number]: { 56 | [ComponentEvent.Added]: number[][]; 57 | [ComponentEvent.Removed]: number[][]; 58 | [ComponentEvent.Changed]: number[][]; 59 | }; 60 | }; 61 | protected systems: ISystem[]; 62 | 63 | constructor() { 64 | this.entityPool = []; 65 | this.entityCount = 0; 66 | this.entityFlags = new Uint32Array(1024); 67 | this.entityMaskLength = 1; 68 | this.componentConstructors = []; 69 | this.componentStorages = []; 70 | this.componentEventQueues = {}; 71 | this.systems = []; 72 | } 73 | 74 | /** 75 | * Create an entity in the world. 76 | */ 77 | public createEntity() { 78 | const entityId = 79 | this.entityPool.length > 0 ? this.entityPool.pop() : ++this.entityCount; 80 | const maskIndex = entityId * this.entityMaskLength; 81 | 82 | if (maskIndex >= this.entityFlags.length) { 83 | const newEntityFlags = new Uint32Array(this.entityFlags.length + 1024); 84 | newEntityFlags.set(this.entityFlags); 85 | this.entityFlags = newEntityFlags; 86 | } 87 | 88 | this.entityFlags[maskIndex] = 1; 89 | 90 | return entityId; 91 | } 92 | 93 | /** 94 | * Destroy an entity. 95 | */ 96 | public destroyEntity(entityId: TEntityId) { 97 | const entityFlags = this.entityFlags; 98 | const entityMaskLength = this.entityMaskLength; 99 | 100 | for (let i = 0; i < entityMaskLength; i++) { 101 | const maskIndex = entityId * entityMaskLength + i; 102 | entityFlags[maskIndex] = 0; 103 | } 104 | 105 | for (const Component of this.componentConstructors) { 106 | this.removeComponent(entityId, Component); 107 | } 108 | 109 | this.entityPool.push(entityId); 110 | } 111 | 112 | /** 113 | * Returns true if an entity has been created and has not been destroyed. 114 | */ 115 | public isAlive(entityId: TEntityId) { 116 | return (this.entityFlags[entityId * this.entityMaskLength] & 1) === 1; 117 | } 118 | 119 | /** 120 | * Register a component class and storage with the world so that it can be queried. 121 | */ 122 | public registerComponent( 123 | Component: IComponentConstructor, 124 | storage?: IComponentStorage 125 | ) { 126 | storage = storage || new MapComponentStorage(); 127 | 128 | const numComponents = this.componentStorages.length; 129 | const maskSize = numComponents + 1; 130 | const id = (Component.id = numComponents); 131 | Component.maskIndex = Math.floor(maskSize / 32); 132 | Component.mask = 1 << maskSize % 32; 133 | this.componentStorages[id] = storage; 134 | this.componentConstructors.push(Component); 135 | this.componentEventQueues[id] = { 136 | [ComponentEvent.Added]: [], 137 | [ComponentEvent.Removed]: [], 138 | [ComponentEvent.Changed]: [] 139 | }; 140 | 141 | const prevEntityMaskLength = this.entityMaskLength; 142 | const nextEntityMaskLength = Math.ceil(maskSize / 32); 143 | 144 | if (nextEntityMaskLength !== prevEntityMaskLength) { 145 | const numEntities = this.entityFlags.length / prevEntityMaskLength; 146 | const prevEntityFlags = this.entityFlags; 147 | const nextEntityFlags = new Uint32Array( 148 | numEntities * nextEntityMaskLength 149 | ); 150 | 151 | for (let i = 0; i < numEntities; i++) { 152 | for (let j = 0; j < prevEntityMaskLength; j++) { 153 | const oldIndex = i * prevEntityMaskLength + j; 154 | const newIndex = i * nextEntityMaskLength + j; 155 | nextEntityFlags[newIndex] = prevEntityFlags[oldIndex]; 156 | } 157 | } 158 | 159 | this.entityFlags = nextEntityFlags; 160 | this.entityMaskLength = nextEntityMaskLength; 161 | } 162 | } 163 | 164 | /** 165 | * Returns true if an entity has the provided component. 166 | */ 167 | public hasComponent( 168 | entityId: TEntityId, 169 | Component: IComponentConstructor 170 | ) { 171 | const maskIndex = entityId * this.entityMaskLength + Component.maskIndex; 172 | const componentMask = Component.mask; 173 | return (this.entityFlags[maskIndex] & componentMask) === componentMask; 174 | } 175 | 176 | /** 177 | * Get an immutable reference to the component on the provided entity. 178 | * 179 | * @remarks In development mode, in order to throw an error when an immutable component is mutated, 180 | * this method returns a proxy of the component, not the original component. 181 | */ 182 | public getImmutableComponent( 183 | entityId: TEntityId, 184 | Component: IComponentConstructor 185 | ): T { 186 | let component = this.componentStorages[Component.id].get(entityId) as T; 187 | 188 | if (process.env.NODE_ENV === "development") { 189 | component = wrapImmutableComponent(component); 190 | } 191 | 192 | return component; 193 | } 194 | 195 | /** 196 | * Get a mutable reference to the component on the provided entity. 197 | * 198 | * @remarks A ComponentEvent.Changed event is pushed to any EventChannels for this component. 199 | */ 200 | public getMutableComponent( 201 | entityId: TEntityId, 202 | Component: IComponentConstructor 203 | ): T { 204 | const componentId = Component.id; 205 | this.pushComponentEvent(componentId, entityId, ComponentEvent.Changed); 206 | return this.componentStorages[componentId].get(entityId) as T; 207 | } 208 | 209 | /** 210 | * Add the component to the provided entity. Returns null if the entity already has the component. 211 | * 212 | * @remarks A ComponentEvent.Added event is pushed to any EventChannels for this component. 213 | */ 214 | public addComponent( 215 | entityId: TEntityId, 216 | component: T 217 | ): T { 218 | const Component = component.constructor as IComponentConstructor; 219 | const componentId = Component.id; 220 | 221 | if (!this.hasComponent(entityId, Component)) { 222 | const maskIndex = entityId * this.entityMaskLength + Component.maskIndex; 223 | const componentMask = Component.mask; 224 | this.entityFlags[maskIndex] |= componentMask; 225 | this.pushComponentEvent(componentId, entityId, ComponentEvent.Added); 226 | return this.componentStorages[Component.id].set(entityId, component) as T; 227 | } 228 | 229 | return null; 230 | } 231 | 232 | /** 233 | * Remove the component from the provided entity. Returns null if the entity doesn't have the component. 234 | * 235 | * @remarks A ComponentEvent.Removed event is pushed to any EventChannels for this component. 236 | */ 237 | public removeComponent( 238 | entityId: TEntityId, 239 | Component: IComponentConstructor 240 | ) { 241 | const componentId = Component.id; 242 | 243 | if (this.componentStorages[componentId].remove(entityId)) { 244 | const maskIndex = entityId * this.entityMaskLength + Component.maskIndex; 245 | const componentMask = Component.mask; 246 | this.entityFlags[maskIndex] &= ~componentMask; 247 | this.pushComponentEvent(componentId, entityId, ComponentEvent.Removed); 248 | return true; 249 | } 250 | 251 | return false; 252 | } 253 | 254 | /** 255 | * Create a query for the provided QueryParameters. 256 | * 257 | * @remarks See EntityId, Read, and Write for details on the different QueryParameters. 258 | */ 259 | public createQuery(a: TQueryParameter): IQuery<[A]>; 260 | public createQuery( 261 | a: TQueryParameter, 262 | b: TQueryParameter 263 | ): IQuery<[A, B]>; 264 | public createQuery( 265 | a: TQueryParameter, 266 | b: TQueryParameter, 267 | c: TQueryParameter 268 | ): IQuery<[A, B, C]>; 269 | public createQuery( 270 | a: TQueryParameter, 271 | b: TQueryParameter, 272 | c: TQueryParameter, 273 | d: TQueryParameter 274 | ): IQuery<[A, B, C, D]>; 275 | public createQuery( 276 | a: TQueryParameter, 277 | b: TQueryParameter, 278 | c: TQueryParameter, 279 | d: TQueryParameter, 280 | e: TQueryParameter 281 | ): IQuery<[A, B, C, D, E]>; 282 | public createQuery( 283 | a: TQueryParameter, 284 | b: TQueryParameter, 285 | c: TQueryParameter, 286 | d: TQueryParameter, 287 | e: TQueryParameter, 288 | f: TQueryParameter 289 | ): IQuery<[A, B, C, D, E, F]>; 290 | public createQuery( 291 | ...parameters: Array> 292 | ): IQuery> { 293 | // tslint:disable-next-line no-this-assignment 294 | const self = this; 295 | const queryMask = new Uint32Array(this.entityMaskLength); 296 | const results: Array = []; 297 | const queryParameters: Array = []; 298 | const componentStorages: Array> = []; 299 | 300 | // Only query for active entities. 301 | queryMask[0] = 1; 302 | 303 | for (let parameter of parameters) { 304 | if (typeof parameter === "function") { 305 | parameter = Read(parameter); 306 | } 307 | 308 | const Component = parameter.component; 309 | 310 | if (Component) { 311 | queryMask[Component.maskIndex] |= Component.mask; 312 | componentStorages.push(this.componentStorages[Component.id]); 313 | } else { 314 | componentStorages.push(undefined); 315 | } 316 | 317 | queryParameters.push(parameter); 318 | results.push(undefined); 319 | } 320 | 321 | function iterator() { 322 | const maskLength = self.entityMaskLength; 323 | const entityCount = self.entityCount; 324 | const entityFlags = self.entityFlags; 325 | 326 | let i = 1; 327 | const result = { value: results, done: false }; 328 | 329 | return { 330 | next() { 331 | while (i <= entityCount) { 332 | let match = true; 333 | 334 | for (let j = 0; j < maskLength; j++) { 335 | const mask = queryMask[j]; 336 | match = 337 | match && (entityFlags[i * maskLength + j] & mask) === mask; 338 | } 339 | 340 | if (match) { 341 | for (let p = 0; p < queryParameters.length; p++) { 342 | const parameter = queryParameters[p] as IQueryOption< 343 | IComponent | TEntityId 344 | >; 345 | 346 | if (parameter.entity) { 347 | results[p] = i; 348 | } else if (parameter.write) { 349 | self.pushComponentEvent( 350 | parameter.component.id, 351 | i, 352 | ComponentEvent.Changed 353 | ); 354 | results[p] = componentStorages[p].get(i); 355 | } else { 356 | let component = componentStorages[p].get(i); 357 | 358 | if (process.env.NODE_ENV === "development") { 359 | component = wrapImmutableComponent(component); 360 | } 361 | 362 | results[p] = component; 363 | } 364 | } 365 | i++; 366 | return result; 367 | } 368 | i++; 369 | } 370 | 371 | result.done = true; 372 | return result; 373 | } 374 | }; 375 | } 376 | 377 | return { 378 | [Symbol.iterator]: iterator, 379 | first() { 380 | return iterator().next().value; 381 | }, 382 | isEmpty() { 383 | return iterator().next().done; 384 | }, 385 | // tslint:disable-next-line: no-empty 386 | destroy() {} 387 | }; 388 | } 389 | 390 | /** 391 | * Create an event channel for the provided ComponentEvent and Component. 392 | */ 393 | public createEventChannel( 394 | event: ComponentEvent, 395 | Component?: IComponentConstructor 396 | ): IEventChannel { 397 | const eventQueues = this.componentEventQueues[Component.id][event]; 398 | const eventQueue: TEntityId[] = []; 399 | eventQueues.push(eventQueue); 400 | const results = [undefined, undefined] as [TEntityId, T]; 401 | const componentStorage = this.componentStorages[Component.id]; 402 | 403 | function iterator() { 404 | let id: TEntityId; 405 | const result = { value: results, done: false }; 406 | 407 | return { 408 | next() { 409 | // tslint:disable-next-line: no-conditional-assignment 410 | if ((id = eventQueue.pop()) !== undefined) { 411 | results[0] = id; 412 | 413 | let component = componentStorage.get(id) as T; 414 | 415 | if (process.env.NODE_ENV === "development") { 416 | component = wrapImmutableComponent(component); 417 | } 418 | 419 | results[1] = component; 420 | } else { 421 | result.done = true; 422 | } 423 | 424 | return result; 425 | } 426 | }; 427 | } 428 | 429 | return { 430 | [Symbol.iterator]: iterator, 431 | first() { 432 | return iterator().next().value; 433 | }, 434 | isEmpty() { 435 | return eventQueue.length === 0; 436 | }, 437 | destroy() { 438 | const index = eventQueues.indexOf(eventQueue); 439 | eventQueues.splice(index, 1); 440 | } 441 | }; 442 | } 443 | 444 | /** 445 | * Register a system with the world. The system's init method will be called. 446 | * 447 | * @remarks Systems are updated in the order they are registered. 448 | */ 449 | public registerSystem(system: ISystem) { 450 | this.systems.push(system); 451 | system.init(this); 452 | } 453 | 454 | /** 455 | * Update all systems registered with the world. 456 | */ 457 | public update() { 458 | for (let i = 0; i < this.systems.length; i++) { 459 | this.systems[i].update(); 460 | } 461 | } 462 | 463 | /** 464 | * Unregister the system. The system's destroy method will be called. 465 | */ 466 | public unregisterSystem(system: ISystem) { 467 | const index = this.systems.indexOf(system); 468 | this.systems[index].destroy(); 469 | this.systems.splice(index, 1); 470 | } 471 | 472 | /** 473 | * Destroy the world and all registered systems. 474 | */ 475 | public destroy() { 476 | for (const system of this.systems) { 477 | system.destroy(); 478 | } 479 | } 480 | 481 | protected pushComponentEvent( 482 | componentId: number, 483 | entityId: number, 484 | event: ComponentEvent 485 | ) { 486 | const componentEventQueues = this.componentEventQueues[componentId]; 487 | if (componentEventQueues) { 488 | const changedEventQueues = componentEventQueues[event]; 489 | 490 | if (changedEventQueues) { 491 | for (let i = 0; i < changedEventQueues.length; i++) { 492 | changedEventQueues[i].push(entityId); 493 | } 494 | } 495 | } 496 | } 497 | } 498 | 499 | export interface IQuery> { 500 | [Symbol.iterator](): Iterator; 501 | first(): T; 502 | isEmpty(): boolean; 503 | destroy(): void; 504 | } 505 | 506 | export interface IEventChannel { 507 | [Symbol.iterator](): Iterator<[TEntityId, T]>; 508 | first(): [TEntityId, T]; 509 | isEmpty(): boolean; 510 | destroy(): void; 511 | } 512 | 513 | export enum ComponentEvent { 514 | Added, 515 | Removed, 516 | Changed 517 | } 518 | 519 | export type TEntityId = number; 520 | 521 | export interface IComponentConstructor { 522 | id?: number; 523 | maskIndex?: number; 524 | mask?: number; 525 | new (...args: any[]): T; 526 | } 527 | 528 | // tslint:disable-next-line: no-empty-interface 529 | export interface IComponent {} 530 | 531 | export interface IComponentStorage { 532 | get(entityId: TEntityId): T | undefined; 533 | set(entityId: TEntityId, component: T): T; 534 | remove(entityId: TEntityId): boolean; 535 | } 536 | 537 | export interface ISystem { 538 | init(world: World): void; 539 | update(): void; 540 | destroy(): void; 541 | } 542 | 543 | export type TQueryParameter = IComponentConstructor | IQueryOption; 544 | 545 | export interface IQueryOption { 546 | entity: boolean; 547 | write: boolean; 548 | component: IComponentConstructor | null; 549 | } 550 | 551 | /** 552 | * Query an entity id. 553 | */ 554 | export const EntityId: IQueryOption = { 555 | component: null, 556 | entity: true, 557 | write: false 558 | }; 559 | 560 | /** 561 | * Query a component as read only. 562 | */ 563 | export function Read(Component: IComponentConstructor): IQueryOption { 564 | return { entity: false, write: false, component: Component }; 565 | } 566 | 567 | /** 568 | * Query a component as read/write. 569 | */ 570 | export function Write(Component: IComponentConstructor): IQueryOption { 571 | return { entity: false, write: true, component: Component }; 572 | } 573 | 574 | export { FlagComponentStorage } from "./FlagComponentStorage"; 575 | export { MapComponentStorage } from "./MapComponentStorage"; 576 | export { SparseArrayComponentStorage } from "./SparseArrayComponentStorage"; 577 | export { System, ISystemContext } from "./System"; 578 | -------------------------------------------------------------------------------- /packages/hecs/test/ComponentStorages.test.ts: -------------------------------------------------------------------------------- 1 | import test, { ExecutionContext } from "ava"; 2 | import { 3 | IComponent, 4 | IComponentStorage, 5 | MapComponentStorage, 6 | SparseArrayComponentStorage 7 | } from "../src"; 8 | 9 | class TestComponent { 10 | public value: number; 11 | 12 | constructor(value: number) { 13 | this.value = value; 14 | } 15 | } 16 | 17 | function createComponentStorageTest( 18 | ComponentStorage: new () => IComponentStorage 19 | ) { 20 | return (t: ExecutionContext) => { 21 | const storage = new ComponentStorage(); 22 | 23 | const component1 = new TestComponent(123); 24 | const component2 = new TestComponent(123); 25 | 26 | t.is( 27 | storage.set(1, component1), 28 | component1, 29 | `${ComponentStorage.name}#set(1, component1) failed to return component1.` 30 | ); 31 | 32 | t.is( 33 | storage.get(1), 34 | component1, 35 | `${ComponentStorage.name}#get(1) failed to return component1.` 36 | ); 37 | 38 | t.is( 39 | storage.remove(1), 40 | true, 41 | `${ComponentStorage.name}#remove(1) failed to return true.` 42 | ); 43 | 44 | t.is( 45 | storage.remove(1), 46 | false, 47 | `${ 48 | ComponentStorage.name 49 | }#remove(1) failed to return false for already removed component.` 50 | ); 51 | 52 | t.is( 53 | storage.get(1), 54 | undefined, 55 | `${ 56 | ComponentStorage.name 57 | }#get(1) failed to return undefined for already removed component.` 58 | ); 59 | 60 | t.is( 61 | storage.set(1, component1), 62 | component1, 63 | `${ 64 | ComponentStorage.name 65 | }#set(1) failed to return component1 on second call.` 66 | ); 67 | 68 | t.is( 69 | storage.set(1, component2), 70 | component2, 71 | `${ 72 | ComponentStorage.name 73 | }#set(1) failed to return component2 on third call.` 74 | ); 75 | 76 | t.is( 77 | storage.get(1), 78 | component2, 79 | `${ComponentStorage.name}#get(1) failed to return component2 after set().` 80 | ); 81 | 82 | t.is( 83 | storage.remove(1), 84 | true, 85 | `${ 86 | ComponentStorage.name 87 | }#remove(1) failed to return true to clear the storage.` 88 | ); 89 | 90 | for (let i = 1; i <= 10; i++) { 91 | const component = new TestComponent(i); 92 | 93 | t.is( 94 | storage.set(i, component), 95 | component, 96 | `${ 97 | ComponentStorage.name 98 | }#set(${i}, component) failed to return component on iteration ${i}.` 99 | ); 100 | 101 | t.is( 102 | storage.get(i), 103 | component, 104 | `${ 105 | ComponentStorage.name 106 | }#get(${i}) failed to return component on iteration ${i}.` 107 | ); 108 | } 109 | 110 | for (let i = 1; i <= 10; i++) { 111 | t.is( 112 | storage.remove(i), 113 | true, 114 | `${ 115 | ComponentStorage.name 116 | }#remove(${i}) failed to return true on iteration ${i}.` 117 | ); 118 | 119 | t.is( 120 | storage.get(i), 121 | undefined, 122 | `${ 123 | ComponentStorage.name 124 | }#get(${i}) failed to return undefined for already removed component on iteration ${i}.` 125 | ); 126 | 127 | t.is( 128 | storage.remove(i), 129 | false, 130 | `${ 131 | ComponentStorage.name 132 | }#set(${i}, component) failed to return false for already removed component on iteration ${i}.` 133 | ); 134 | } 135 | }; 136 | } 137 | 138 | test("MapComponentStorage", createComponentStorageTest(MapComponentStorage)); 139 | test( 140 | "SparseArrayComponentStorage", 141 | createComponentStorageTest(SparseArrayComponentStorage) 142 | ); 143 | -------------------------------------------------------------------------------- /packages/hecs/test/System.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { 3 | ComponentEvent, 4 | IEventChannel, 5 | IQuery, 6 | ISystemContext, 7 | MapComponentStorage, 8 | System, 9 | World, 10 | Write 11 | } from "../src"; 12 | 13 | interface ITestContext extends ISystemContext { 14 | entities: IQuery<[TestComponent, ObserverComponent]>; 15 | added: IEventChannel; 16 | changed: IEventChannel; 17 | removed: IEventChannel; 18 | } 19 | 20 | class TestComponent { 21 | public value: number; 22 | 23 | constructor(value: number) { 24 | this.value = value; 25 | } 26 | } 27 | 28 | class ObserverComponent { 29 | public frame: number; 30 | public addedThisFrame: number; 31 | public changedThisFrame: number; 32 | public removedThisFrame: number; 33 | 34 | constructor() { 35 | this.frame = 0; 36 | this.addedThisFrame = -1; 37 | this.changedThisFrame = -1; 38 | this.removedThisFrame = -1; 39 | } 40 | } 41 | 42 | class TestSmartSystem extends System { 43 | public setup() { 44 | return { 45 | added: this.world.createEventChannel(ComponentEvent.Added, TestComponent), 46 | changed: this.world.createEventChannel( 47 | ComponentEvent.Changed, 48 | TestComponent 49 | ), 50 | entities: this.world.createQuery( 51 | Write(TestComponent), 52 | Write(ObserverComponent) 53 | ), 54 | removed: this.world.createEventChannel( 55 | ComponentEvent.Removed, 56 | TestComponent 57 | ) 58 | }; 59 | } 60 | 61 | public update() { 62 | for (const [testComponent, observerComponent] of this.ctx.entities) { 63 | testComponent.value++; 64 | observerComponent.frame++; 65 | } 66 | 67 | for (const [entityId, testComponent] of this.ctx.added) { 68 | const observerComponent = this.world.getMutableComponent( 69 | entityId, 70 | ObserverComponent 71 | ); 72 | observerComponent.addedThisFrame = observerComponent.frame; 73 | } 74 | 75 | for (const [entityId, testComponent] of this.ctx.changed) { 76 | const observerComponent = this.world.getMutableComponent( 77 | entityId, 78 | ObserverComponent 79 | ); 80 | observerComponent.changedThisFrame = observerComponent.frame; 81 | } 82 | 83 | for (const [entityId, testComponent] of this.ctx.removed) { 84 | const observerComponent = this.world.getMutableComponent( 85 | entityId, 86 | ObserverComponent 87 | ); 88 | observerComponent.removedThisFrame = observerComponent.frame + 1; 89 | } 90 | } 91 | } 92 | 93 | test("SmartSystem", t => { 94 | const world = new World(); 95 | world.registerComponent(TestComponent, new MapComponentStorage()); 96 | world.registerComponent(ObserverComponent, new MapComponentStorage()); 97 | 98 | const system = new TestSmartSystem(); 99 | world.registerSystem(system); 100 | 101 | const entityId = world.createEntity(); 102 | const testComponent = new TestComponent(11); 103 | const observerComponent = new ObserverComponent(); 104 | 105 | world.addComponent(entityId, testComponent); 106 | world.addComponent(entityId, observerComponent); 107 | 108 | world.update(); 109 | 110 | t.is(testComponent.value, 12); 111 | t.is(observerComponent.frame, 1); 112 | t.is(observerComponent.addedThisFrame, 1); 113 | t.is(observerComponent.changedThisFrame, 1); 114 | t.is(observerComponent.removedThisFrame, -1); 115 | 116 | world.update(); 117 | 118 | t.is(testComponent.value, 13); 119 | t.is(observerComponent.frame, 2); 120 | t.is(observerComponent.addedThisFrame, 1); 121 | t.is(observerComponent.changedThisFrame, 2); 122 | t.is(observerComponent.removedThisFrame, -1); 123 | 124 | world.removeComponent(entityId, TestComponent); 125 | 126 | world.update(); 127 | 128 | t.is(testComponent.value, 13); 129 | t.is(observerComponent.frame, 2); 130 | t.is(observerComponent.addedThisFrame, 1); 131 | t.is(observerComponent.changedThisFrame, 2); 132 | t.is(observerComponent.removedThisFrame, 3); 133 | 134 | world.unregisterSystem(system); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/hecs/test/World.test.ts: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { 3 | ComponentEvent, 4 | IComponent, 5 | IComponentConstructor, 6 | MapComponentStorage, 7 | World 8 | } from "../src"; 9 | 10 | class TestComponent { 11 | public value: number; 12 | 13 | constructor(value: number) { 14 | this.value = value; 15 | } 16 | } 17 | 18 | class TestWorld extends World { 19 | public getEntityFlags() { 20 | return this.entityFlags; 21 | } 22 | 23 | public getEntityMaskLength() { 24 | return this.entityMaskLength; 25 | } 26 | 27 | public getComponentStorages() { 28 | return this.componentStorages; 29 | } 30 | } 31 | 32 | test("World#createEntity()", t => { 33 | const world = new World(); 34 | t.is(world.createEntity(), 1); 35 | 36 | // Test expanding the entityFlags typed array 37 | for (let i = 0; i < 1024; i++) { 38 | world.createEntity(); 39 | } 40 | t.true(world.isAlive(1)); 41 | t.true(world.isAlive(1024)); 42 | t.true(world.isAlive(1025)); 43 | }); 44 | 45 | test("World#registerComponent()", t => { 46 | const world = new TestWorld(); 47 | 48 | world.registerComponent(TestComponent, new MapComponentStorage()); 49 | 50 | const entity = world.createEntity(); 51 | world.addComponent(entity, new TestComponent(123)); 52 | 53 | t.is(world.getEntityFlags().length, 1024); 54 | t.is(world.getEntityMaskLength(), 1); 55 | 56 | let Component: IComponentConstructor; 57 | 58 | for (let i = 0; i < 32; i++) { 59 | Component = class implements IComponent {}; 60 | world.registerComponent(Component, new MapComponentStorage()); 61 | } 62 | 63 | t.is(world.getComponentStorages().length, 33); 64 | t.is(Component.id, 32); 65 | 66 | world.addComponent(entity, new Component()); 67 | 68 | const entityFlags = world.getEntityFlags(); 69 | t.is(entityFlags.length, 2048); 70 | t.is(entityFlags[2], 0b11); 71 | t.is(entityFlags[3], 0b10); 72 | t.is(world.getEntityMaskLength(), 2); 73 | t.is(world.getImmutableComponent(entity, TestComponent).value, 123); 74 | }); 75 | 76 | test("World#addComponent()", t => { 77 | const world = new World(); 78 | 79 | world.registerComponent(TestComponent, new MapComponentStorage()); 80 | 81 | const addedEvents = world.createEventChannel( 82 | ComponentEvent.Added, 83 | TestComponent 84 | ); 85 | 86 | const entityId = world.createEntity(); 87 | 88 | const component = new TestComponent(123); 89 | 90 | t.is(world.addComponent(entityId, component), component); 91 | 92 | t.deepEqual(addedEvents.first(), [entityId, component]); 93 | }); 94 | 95 | test("World#getImmutableComponent()", t => { 96 | const world = new World(); 97 | 98 | world.registerComponent(TestComponent, new MapComponentStorage()); 99 | 100 | const changedEvents = world.createEventChannel( 101 | ComponentEvent.Changed, 102 | TestComponent 103 | ); 104 | 105 | const entityId = world.createEntity(); 106 | 107 | const component = new TestComponent(123); 108 | 109 | world.addComponent(entityId, component); 110 | 111 | t.is( 112 | world.getImmutableComponent(entityId, TestComponent).value, 113 | component.value 114 | ); 115 | 116 | t.true(changedEvents.isEmpty()); 117 | 118 | t.throws(() => { 119 | const immutableComponent = world.getImmutableComponent( 120 | entityId, 121 | TestComponent 122 | ); 123 | immutableComponent.value = 999; 124 | }, /immutable/g); 125 | }); 126 | 127 | test("World#getMutableComponent()", t => { 128 | const world = new World(); 129 | 130 | world.registerComponent(TestComponent, new MapComponentStorage()); 131 | 132 | const changedEvents = world.createEventChannel( 133 | ComponentEvent.Changed, 134 | TestComponent 135 | ); 136 | 137 | const entityId = world.createEntity(); 138 | 139 | const component = new TestComponent(123); 140 | 141 | world.addComponent(entityId, component); 142 | 143 | t.is(world.getMutableComponent(entityId, TestComponent), component); 144 | 145 | t.deepEqual(changedEvents.first(), [entityId, component]); 146 | 147 | t.notThrows(() => { 148 | const mutableComponent = world.getMutableComponent(entityId, TestComponent); 149 | mutableComponent.value = 999; 150 | }); 151 | }); 152 | 153 | test("World#removeComponent()", t => { 154 | const world = new World(); 155 | 156 | world.registerComponent(TestComponent, new MapComponentStorage()); 157 | 158 | const removedEvents = world.createEventChannel( 159 | ComponentEvent.Removed, 160 | TestComponent 161 | ); 162 | 163 | const entityId = world.createEntity(); 164 | 165 | const component = new TestComponent(123); 166 | 167 | world.addComponent(entityId, component); 168 | 169 | t.is( 170 | world.getImmutableComponent(entityId, TestComponent).value, 171 | component.value 172 | ); 173 | 174 | t.is(world.removeComponent(entityId, TestComponent), true); 175 | 176 | t.deepEqual(removedEvents.first(), [entityId, undefined]); 177 | }); 178 | 179 | test("World#hasComponent()", t => { 180 | const world = new World(); 181 | 182 | world.registerComponent(TestComponent, new MapComponentStorage()); 183 | 184 | const entityId = world.createEntity(); 185 | 186 | const component = new TestComponent(123); 187 | 188 | world.addComponent(entityId, component); 189 | 190 | t.is(world.hasComponent(entityId, TestComponent), true); 191 | 192 | world.removeComponent(entityId, TestComponent); 193 | 194 | t.is(world.hasComponent(entityId, TestComponent), false); 195 | }); 196 | 197 | test("World#destroyEntity", t => { 198 | const world = new World(); 199 | 200 | world.registerComponent(TestComponent, new MapComponentStorage()); 201 | 202 | const removedEvents = world.createEventChannel( 203 | ComponentEvent.Removed, 204 | TestComponent 205 | ); 206 | 207 | const entityId = world.createEntity(); 208 | 209 | const component = new TestComponent(123); 210 | 211 | world.addComponent(entityId, component); 212 | 213 | t.is( 214 | world.getImmutableComponent(entityId, TestComponent).value, 215 | component.value 216 | ); 217 | 218 | world.destroyEntity(entityId); 219 | 220 | t.deepEqual(removedEvents.first(), [entityId, undefined]); 221 | }); 222 | -------------------------------------------------------------------------------- /packages/hecs/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "lib-cjs", 6 | "module": "commonjs", 7 | "target": "es2015" 8 | }, 9 | "exclude": [ 10 | "./test/**/*.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /packages/hecs/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib-esm", 5 | "module": "es2015", 6 | "target": "es2015" 7 | }, 8 | "exclude": [ 9 | "./test/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/hecs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "include": [ 4 | "./src/**/*.ts", 5 | "./test/**/*.ts" 6 | ] 7 | } -------------------------------------------------------------------------------- /packages/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2015", 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "declaration": true, 8 | "pretty": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true 11 | } 12 | } -------------------------------------------------------------------------------- /packages/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const HTMLWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | module.exports = { 5 | mode: "development", 6 | devtool: "inline-source-map", 7 | output: { 8 | filename: "bundle.js" 9 | }, 10 | resolve: { 11 | extensions: [".ts", ".tsx", ".js"] 12 | }, 13 | module: { 14 | rules: [ 15 | { test: /\.ts$/, loader: "ts-loader", options: { configFile: process.env.TS_NODE_PROJECT } } 16 | ] 17 | }, 18 | plugins: [ 19 | new webpack.DefinePlugin({ 20 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 21 | }), 22 | new HTMLWebpackPlugin() 23 | ] 24 | }; -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-config-prettier", 5 | "tslint-plugin-prettier" 6 | ], 7 | "rules": { 8 | "prettier": true, 9 | "prefer-for-of": false, 10 | "no-implicit-dependencies": false, 11 | "max-classes-per-file": false 12 | }, 13 | "linterOptions": { 14 | "exclude": [ 15 | "**/node_modules/**", 16 | "**/lib*/**" 17 | ] 18 | } 19 | } --------------------------------------------------------------------------------