├── .eslintrc ├── .gitignore ├── .prettierrc.cjs ├── .travis.yml ├── CONTRIBUTING.md ├── DOC.md ├── LICENSE ├── README.md ├── benchmark ├── benchmark-macbookpro18.json ├── benchmark.ts ├── comparator.ts ├── entity.bench.ts └── index.ts ├── example.gif ├── example ├── circle-boxes │ └── index.html ├── circle-intersections │ ├── components.js │ ├── index.html │ └── systems.js └── typescript │ └── circle-boxes │ ├── index.html │ └── index.ts ├── package.json ├── rollup.config.js ├── scripts ├── copy-to-dist.js └── publish.sh ├── src ├── component.ts ├── constants.ts ├── data │ └── observer.ts ├── decorators.ts ├── entity.ts ├── index.ts ├── internals │ ├── archetype.ts │ ├── component-manager.ts │ ├── query-manager.ts │ └── system-manager.ts ├── pool.ts ├── property.ts ├── query.ts ├── system-group.ts ├── system.ts ├── types.ts ├── utils.ts └── world.ts ├── test └── unit │ ├── component.test.ts │ ├── entity.test.ts │ ├── pooling.test.ts │ ├── property.test.ts │ ├── query.test.ts │ ├── system.test.ts │ ├── utils.ts │ └── world.test.ts ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "es6": true, "browser": true }, 3 | 4 | "parserOptions": { 5 | "sourceType": "module" 6 | }, 7 | 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "prettier/@typescript-eslint" 14 | ], 15 | 16 | "plugins": [ 17 | "@typescript-eslint", 18 | "prettier" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | 5 | dist/ 6 | coverage/ 7 | 8 | # Ignores generated js files in TS examples 9 | ./examples/typescript/**/*.js 10 | 11 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | semi: true, 4 | trailingComma: 'none', 5 | singleQuote: true, 6 | printWidth: 80, 7 | tabWidth: 2, 8 | endOfLine: 'lf' 9 | }; 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - develop 5 | node_js: 6 | - node 7 | script: 8 | - npm run lint 9 | - npm run build:ts 10 | - npm run build:umd 11 | - npm run test 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Install 4 | 5 | Start first by cloning the package and installing the development dependencies: 6 | 7 | ```sh 8 | cd ecstra && yarn 9 | ``` 10 | 11 | ## Tests 12 | 13 | You can run the tests using: 14 | 15 | ```sh 16 | yarn test 17 | ``` 18 | 19 | Alternatively, you can run a single file: 20 | 21 | ```sh 22 | yarn test -- [PATH_TO_FILE] 23 | ``` 24 | 25 | ## Development Server 26 | 27 | You can start the development server using: 28 | 29 | ```sh 30 | yarn start 31 | ``` 32 | 33 | This will listen for changes in the `src/` directory. 34 | 35 | ## Example Server 36 | 37 | In order to try your local changes, you can either use the tests, or create 38 | an example making good use of your feature / bug fix. 39 | 40 | You can start the web server for the examples with: 41 | 42 | ```sh 43 | yarn example 44 | ``` 45 | 46 | You can then navigate to `http://localhost:8080/example/[EXAMPLE_NAME]` to try 47 | it out. 48 | 49 | ## Local Link 50 | 51 | In order to link this package to another one, you can either use `yarn add file:[PATH_TO_ECSTRA]` 52 | with a local path, or `yarn link`. 53 | 54 | Just be careful: the package that is published (and so that you should link) 55 | is in the `dist` folder. 56 | 57 | ```sh 58 | yarn build 59 | cd dist 60 | yarn link # Link the package generated in the `dist` folder 61 | ``` 62 | 63 | In you local application, you can now use the `ecstra` package: 64 | 65 | ```sh 66 | cd my-app 67 | yarn link ecstra 68 | ``` 69 | 70 | ```js 71 | import { World } from 'ecstra'; 72 | 73 | // You can use Ecstra as if it was a npm-installed dependency 74 | const world = new World(); 75 | ``` 76 | 77 | ## Benchmark 78 | 79 | Before being merged, the code is passed into the benchmark test suite to ensure 80 | no performance has been lost. 81 | 82 | To run the benchmark locally, you can use: 83 | 84 | ```sh 85 | npm run benchmark -- -o [PATH_TO_OUTPUT_FILE] 86 | ``` 87 | 88 | If you wish to compare the benchmark with a previous result, you can use: 89 | 90 | ```sh 91 | npm run benchmark -- -c [PATH_TO_FILE_TO_COMPARE] 92 | ``` 93 | -------------------------------------------------------------------------------- /DOC.md: -------------------------------------------------------------------------------- 1 | # World 2 | 3 | The `World` is the data structure holding all entities, as well as all the systems. In order to create entities and add components to them, you will need to create a world first. 4 | 5 | ## Creation 6 | 7 | ```js 8 | import { World } from 'ecstra'; 9 | 10 | const world = new World(); 11 | ``` 12 | 13 | You can also change the configuration of the world: 14 | 15 | ```js 16 | const world = new World({ 17 | systems, // List of default systems 18 | maxComponentType, // Maximum number of component registered 19 | useManualPooling, // If `true`, user need to manage memory pools 20 | EntityClass // Entity class to instanciate on entity creation 21 | ); 22 | ``` 23 | 24 | > You can read the API section to get all the information about the world. 25 | 26 | ## Registration 27 | 28 | You must register every system you want to run: 29 | 30 | ```js 31 | world.register(MySystemClass, { 32 | ... // System options 33 | }); 34 | ``` 35 | 36 | It's possible to register a system at any time. However, registering 37 | a system comes at the cost of pre-computing static queries. Doing the 38 | registration early can be benefitial. 39 | 40 | # Entities 41 | 42 | Entities should be created using a world: 43 | 44 | ```js 45 | const entity = world.create('nameOfTheEntity'); 46 | ``` 47 | 48 | > Entities belong to a given world and can't be exchange / shared between two world instances. 49 | 50 | Destroying an entity at the opposite can be done directly on the entity 51 | itself: 52 | 53 | ```js 54 | entity.destroy(); 55 | ``` 56 | 57 | You must add component to entity in order to query them in systems: 58 | 59 | ```js 60 | entity.addComponent(MyComponent, { 61 | ... // Component options 62 | }); 63 | ``` 64 | 65 | ## Retrieving & Deleting Components 66 | 67 | If you plan on reading a component, please use 68 | 69 | ```js 70 | import { ComponentData, StringProp } from 'ecstra'; 71 | 72 | class MyComponent extends ComponentData {} 73 | 74 | MyComponent.Properties = { 75 | myString: StringProp('Hello World!') 76 | }; 77 | 78 | const component = entity.read(MyComponent); 79 | console.log(component.myString); // 'Hello World!' 80 | ``` 81 | 82 | You can also retrieve a component in read-write mode 83 | 84 | ```js 85 | const component = entity.write(MyComponent); 86 | component.myString = 'Hello Ecstra!'; 87 | console.log(component.myString); // 'Hello Ecstra!' 88 | ``` 89 | 90 | > NOTE: Right now, reading a component as read-only or as read-write 91 | > doesn't d **anything**. The idea is to introduce soon a way to improve 92 | > query with this information. 93 | 94 | When you are done with a component, you can get rid of it by using: 95 | 96 | ```js 97 | entity.remove(MyComponent); 98 | ``` 99 | 100 | If you often create and destroy components, consider using 101 | pooling to increase performance. For more information, please have 102 | a look at the ['Pooling' section](#pooling). 103 | 104 | # Components 105 | 106 | Components contain data attached to entity and used by systems to 107 | apply logic on those entities. Ecstra exposes several type of components that will serve different purposes. 108 | 109 | ## ComponentData 110 | 111 | Components deriving `ComponentData` can use the automatic initialization and copy of components. `ComponentData` can declare a property schema and the component will be initialized automatically. 112 | 113 | ```js 114 | import { ComponentData, NumberProp } from 'ecstra'; 115 | 116 | class HealthComponent extends ComponentData {} 117 | 118 | HealthComponent.Properties = { 119 | value: NumberProp(100) // 100 is a default value 120 | }; 121 | 122 | const component = new HealthComponent(); 123 | console.log(component.value); // '100' 124 | ``` 125 | 126 | > NOTE: TypeScript users can declare propertiess with [decorators](#decorators). 127 | 128 | The `DataComponent` class exposes a simple interface: 129 | 130 | ```js 131 | export class Component { 132 | init(source) {} 133 | copy(source) {} 134 | clone() {} 135 | } 136 | ``` 137 | 138 | * `init()` ⟶ initialize the component with an object containing the same 139 | properties. Missing properties will default to the default value set in the `Properties` schema 140 | * `copy(source)` ⟶ Copies the data from the source object, i.e: the same 141 | kind of component or an object with the same properties 142 | * `clone()` ⟶ Returns a new instance of the object with the same properties 143 | 144 | Thankfully, you will not need to override those methods if you use the `Properties` schema. Those methods will automatically use the properties definition in order to know how to initialize and copy the component. 145 | 146 | Ecstra already comes with a few basic property types: 147 | 148 | * `BooleanProp` ⟶ Defaults to `false` 149 | * `NumberProp` ⟶ Defaults to `0` 150 | * `StringProp` ⟶ Defaults to `''` 151 | * `ArrayProp` ⟶ Defaults to `[]` 152 | * `RefProp` 153 | * Use it to store reference to object 154 | * Defaults to `null` 155 | * `CopyableProp` 156 | * Use it on types implementing `copy()` and `clone()` 157 | * Defaults to `new type()`, with `type` a given class 158 | 159 | For more information about how to create a custom property, please have a look at the ['Custom Properties' section](#custom-properties). 160 | 161 | ## TagComponent 162 | 163 | Tags are special kind of components. They hold no data and are used to select entity in queries. 164 | 165 | ```js 166 | class PlayerTagComponent extends TagComponent {} 167 | ``` 168 | 169 | You can then attach this component to a _"player"_ entity. This tag 170 | component will allow you to select the player in systems and performs 171 | custom logic. 172 | 173 | ## SingletonComponent 174 | 175 | Coming soon. 176 | 177 | # Systems 178 | 179 | Systems are run when the world ticks. They are scheduled to run one after 180 | the other, one group at a time. Systems can query entities based on the components they hold. 181 | 182 | ```js 183 | import { System } from 'ecstra'; 184 | 185 | class PhysicsSystem extends System { 186 | 187 | init() { 188 | // Triggered on initialization. Note: you can also use the 189 | // constructor for that. 190 | } 191 | 192 | execute(delta) { 193 | // Performs update logic here. 194 | } 195 | 196 | dispose() { 197 | // Triggered when system is removed from the world. 198 | } 199 | 200 | } 201 | ``` 202 | 203 | Systems have the following lifecycle: 204 | * `init()` → Triggered upon system instanciation in the world 205 | * `execute()` → Triggered when the world execute 206 | * `dispose()` → Triggered when system is destroyed by the world 207 | 208 | ## Order 209 | 210 | ### Topological 211 | 212 | It's possible to declare relation between system of a same group. Then, the 213 | group will be sorted based on those relations. Currently, it's possible to 214 | define hierarchies using: 215 | 216 | * `UpdateBefore(list)` ⟶ the system will run **before** all other systems listed 217 | * `UpdateAfter(list)` ⟶ the system will run **after** all other systems listed 218 | 219 | ```js 220 | import { System } from 'ecstra'; 221 | 222 | class SystemA extends System { 223 | execute() {} 224 | } 225 | SystemA.UpdateAfter = [ SystemC, SystemB ]; 226 | 227 | class SystemB extends System { 228 | execute() {} 229 | } 230 | class SystemC extends System { 231 | execute() {} 232 | } 233 | SystemC.UpdateBefore = [ SystemA ]; 234 | SystemC.UpdateAfter = [ SystemB ]; 235 | ``` 236 | 237 | The group will automatically be sorted using those relations and will the final 238 | group will be `[ SystemB, SystemC, SystemA ]`. 239 | 240 | ### Index-based 241 | 242 | Sorting topologically is nice, but you may want to change ordering after the 243 | world is setup with a simple priority system. 244 | 245 | Systems can be registered with an order that define the position of execution: 246 | 247 | ```js 248 | world.register(SystemB, { order: 0 }); 249 | world.register(SystemC, { order: 1 }); 250 | world.register(SystemA, { order: 2 }); 251 | ``` 252 | 253 | ### Notes 254 | 255 | At any time, you can change the ordering of systems either by modifying the 256 | `order` attribute, or even by modifying the static `UpdateBefore` and 257 | `UpdateAfter` properties (not recommended). 258 | 259 | You will simply need to retrieve the group and call the `sort()` method to 260 | ask for a refresh order of the list: 261 | 262 | ```js 263 | const system = world.system(SystemC); 264 | system.order = 10; 265 | system.group.sort(); 266 | 267 | ``` 268 | 269 | # Queries 270 | 271 | System may have a `Queries` static properties that list all the queries you want to 272 | cache. Queries are created upon system instanciation, and are 273 | cached until the system is unregistered. 274 | 275 | ```js 276 | import { NumberProp, System } from 'ecstra'; 277 | 278 | class TransformComponent extends ComponentData { } 279 | TransformComponent.Properties = { 280 | x: NumberProp(), // Defaults to 0. 281 | y: NumberProp(), // Defaults to 0. 282 | }; 283 | 284 | class SpeedComponent extends ComponentData { } 285 | SpeedComponent.Properties = { 286 | value: NumberProp(150.0) 287 | }; 288 | 289 | class PhysicsSystem extends System { 290 | 291 | execute(delta) { 292 | // `query` contains **every** entity that has at least the 293 | // components `SpeedComponent` and `TransformComponent`. 294 | const query = this.queries.entitiesWithBox; 295 | // Loops over every entity. 296 | query.execute((entity) => { 297 | const transform = entity.write(TransformComponent); 298 | const speed = entity.read(SpeedComponent); 299 | transform.y = Math.max(0.0, transform.y - speed.value * delta); 300 | }); 301 | } 302 | 303 | } 304 | // The static property `Queries` list the query you want to automatically 305 | // create with the system. 306 | PhysicsSystem.Queries = { 307 | // The `entitiesWithBox` matches every entity with the `SpeedComponent` and 308 | // `TransformComponent` components. 309 | entitiesWithBox: [ SpeedComponent, TransformComponent ] 310 | }; 311 | ``` 312 | 313 | ## Operators 314 | 315 | ### Not 316 | 317 | Queries can also specify that they want to deal with entities that 318 | **do not** have a given component: 319 | 320 | ```js 321 | import { Not } from 'ecstra'; 322 | 323 | ... 324 | 325 | PhysicsSystem.Queries = { 326 | // Matches entities with `SpeedComponent` and `TransformComponent but 327 | // without `PlayerComponent`. 328 | entitiesWithBoxThatArentPlayers: [ 329 | SpeedComponent, 330 | TransformComponent, 331 | Not(PlayerComponent) 332 | ] 333 | }; 334 | ``` 335 | 336 | ## Events 337 | 338 | A query will notifiy when a new entity is matching its component 339 | layout: 340 | 341 | ```js 342 | class MySystem extends System { 343 | 344 | init() { 345 | this.queries.myQuery.onEntityAdded = () => { 346 | // Triggered when a new entity matches the component layout of the 347 | // query `myQuery`. 348 | }; 349 | this.queries.myQuery.onEntityRemoved = () => { 350 | // Triggered when an entity that was previously matching query isn't 351 | // matching anymore. 352 | }; 353 | } 354 | 355 | } 356 | MySystem.Queries = { 357 | myQuery: [ ... ] 358 | } 359 | ``` 360 | 361 | You can use those two events to perform initialization and disposal of 362 | resources. 363 | 364 | ### Events Order 365 | 366 | Those events are **synchronous** and can be called in **any** order. If you 367 | have two queries, never assumes the `onEntityAdded` and `onEntityRemoved` events 368 | of one will be triggered before the other. 369 | 370 | > NOTE: the current behaviour could be later changed in the library if 371 | > events **must** be based on the systems execution order. 372 | 373 | # Decorators 374 | 375 | ## ComponentData 376 | 377 | For TypeScript users, it's possible to use decorators to declare the properties: 378 | 379 | ```ts 380 | class TestComponentDecorator extends ComponentData { 381 | @boolean 382 | myBoolean: boolean = true; 383 | 384 | @number 385 | myNumber: number = 10; 386 | 387 | @string 388 | myString: string = 'my string!'; 389 | 390 | @array 391 | myStringArray: string[] = []; 392 | 393 | @reference 394 | myRef: Object | null = null; 395 | } 396 | ``` 397 | 398 | # Pooling 399 | 400 | The first version of Ecstra had pooling disabled by default. However, when I 401 | started to benchmark the library I quickly realized that pooling was a must have 402 | by default. 403 | 404 | By default, every component type and entities have associated pools. If you have 405 | 50 different components, Ecstra will then allocates 50 component pools and one 406 | extra pool for entities. This may seem like a waste of memory, but will bring 407 | by ~50% the cost of creating components and entities. 408 | 409 | ## Custom Pool 410 | 411 | You can derive your own pool implementation by creating a class 412 | matching this interface: 413 | 414 | ```js 415 | export interface ObjectPool { 416 | destroy?: () => void; 417 | acquire(): T; 418 | release(value: T): void; 419 | expand(count: number): void; 420 | } 421 | ``` 422 | 423 | You can then use your own default pool for entities / components: 424 | 425 | ```js 426 | const world = new World({ 427 | ComponentPoolClass: MySuperFastPoolClass, 428 | EntityPoolClass: MySuperFastPoolClass 429 | }); 430 | ``` 431 | 432 | Alternatively, you can change the pool on a per-component basis using: 433 | 434 | ```js 435 | world.registerComponent(MyComponentClass, { pool: MySuperFastPoolClass }); 436 | ``` 437 | 438 | or 439 | 440 | ```js 441 | world.setComponentPool(MyComponentClass, MySuperFastPoolClass); 442 | ``` 443 | 444 | ## Disable Automatic Pooling 445 | 446 | If you don't want any default pooling, you can create your `World` using: 447 | 448 | ```js 449 | const world = new World({ 450 | useManualPooling: true 451 | }) 452 | ``` 453 | 454 | When the automatic pooling is disabled, `ComponentPoolClass` and 455 | `EntityPoolClass` are unused. However, manually assigning pool using 456 | `world.setComponentPool` is still a possibility. 457 | 458 | # Perfomance 459 | 460 | ## Pooling 461 | 462 | Pooling can significantly improve performance, especially if you often add or 463 | remove components. The default pooling scheme should be enough in most cases, 464 | but creating custom pool systems can also help. 465 | 466 | ## Reduce Componet Addition / Deletion 467 | 468 | Even if pooling is used, adding / deleting components always comes at a cost. 469 | The components list is hashed into a string, used to find the new archetype 470 | of the entity. 471 | 472 | You can probably enabled / disable some components by using a custom field. 473 | 474 | # Advanced 475 | 476 | ## Custom Properties 477 | 478 | You can create your own properties by extending the `Property` class: 479 | 480 | ```js 481 | import { Property } from 'property'; 482 | 483 | class MyProperty extends Property { 484 | 485 | copy(dest, src) { 486 | // Optional method to implement. 487 | // `dest` should receive the value (for reference type). 488 | // `src` is the input. 489 | return dest; 490 | } 491 | 492 | } 493 | ``` 494 | 495 | You can also create a function that setup your property: 496 | 497 | ```js 498 | function MyProp(options) { 499 | // Process the `options` argument and create the property. 500 | return new MyProperty(...); 501 | } 502 | ``` 503 | 504 | ## API 505 | 506 | Please have a look at the [generated API]() (coming soon). 507 | 508 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Peicho 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ecstra 2 | 3 | [![Build Status](https://travis-ci.com/DavidPeicho/ecstra.svg?branch=main)](https://travis-ci.com/DavidPeicho/ecstra) 4 | 5 | 6 | > 🚧 Ecstra is a work-in-progress and might be unstable, use it at your 7 | > own risks 🚧 8 | 9 | Fast & Flexible EntityComponentSystem (ECS) for JavaScript and Typescript, available in browser and Node.js. 10 | 11 | Get started with: 12 | * The [Documentation](./DOC.md) 13 | * The [JavaScript Examples](./example) 14 | * The [TypeScript Examples](./example/typescript) 15 | 16 | > 🔍 I am currently looking for people to help me to identify their needs in order to drive the development of this [library further](#stable-version). 17 | 18 |

19 | 20 |

21 | 22 | ## Philosophy 23 | 24 | > Created as 'Flecs', it's been renamed to 'Ecstra' to avoid duplicate 25 | 26 | Ecstra (pronounced as "extra") is heavily based on [Ecsy](https://github.com/ecsyjs/ecsy), but mixes concepts from other great ECS. It also share some concepts with 27 | [Hecs](https://github.com/gohyperr/hecs/). 28 | 29 | My goals for the library is to keep it: 30 | 31 | * 💻 Framework Agnostic 💻 32 | * 🪶 Lightweight 🪶 33 | * ⚡ Fast ⚡ 34 | * 🏋️ Robust 🏋️ 35 | 36 | The library will prioritize stability improvements over feature development. 37 | 38 | ## Features 39 | 40 | * Easy To Use Query Language 41 | * System Grouping 42 | * System Topological Sorting 43 | * Automatic Component Registration 44 | * Component Properties Merging 45 | * System Queries Merging 46 | * TypeScript Decorators 47 | * For component properties 48 | * For system ordering and configuration 49 | * No Dependency 50 | 51 | ## Install 52 | 53 | Using npm: 54 | 55 | ```sh 56 | npm install ecstra 57 | ``` 58 | 59 | Using yarn 60 | 61 | ```sh 62 | yarn add ecstra 63 | ``` 64 | 65 | The library is distributed as an ES6 module, but also comes with two UMD builds: 66 | * `fecs/umd/fecs.js` → Development build with debug assertions 67 | * `fecs/umd/fecs.min.js` → Minified production build, without debug assertions 68 | 69 | ## Usage Example 70 | 71 | ### TypeScript 72 | 73 | ```ts 74 | import { 75 | ComponentData, 76 | TagComponent, 77 | System, 78 | World, 79 | number, 80 | queries, 81 | ref 82 | } from 'ecstra'; 83 | 84 | /** 85 | * Components definition. 86 | */ 87 | 88 | class Position2D extends ComponentData { 89 | @number() 90 | x!: number; 91 | @number() 92 | y!: number; 93 | } 94 | 95 | class FollowTarget extends ComponentData { 96 | @ref() 97 | target!: number; 98 | @number(1.0) 99 | speed!: number; 100 | } 101 | 102 | class PlayerTag extends TagComponent {} 103 | class ZombieTag extends TagComponent {} 104 | 105 | /** 106 | * Systems definition. 107 | */ 108 | 109 | @queries({ 110 | // Select entities with all three components `ZombieTag`, `FollowTarget`, and 111 | // `Position2D`. 112 | zombies: [ZombieTag, FollowTarget, Position2D] 113 | }) 114 | class ZombieFollowSystem extends System { 115 | 116 | execute(delta: number): void { 117 | this.queries.zombies.execute((entity) => { 118 | const { speed, target } = entity.read(FollowTarget); 119 | const position = entity.write(Position2D); 120 | const deltaX = target.x - position.x; 121 | const deltaY = target.y - position.y; 122 | const len = Math.sqrt(deltaX * deltaX + deltaY * deltaY); 123 | if (len >= 0.00001) { 124 | position.x += speed * delta * (deltaX / len); 125 | position.y += speed * delta * (deltaY / len); 126 | } 127 | }); 128 | } 129 | 130 | } 131 | 132 | const world = new World().register(ZombieFollowSystem); 133 | 134 | // Creates a player entity. 135 | const playerEntity = world.create().add(PlayerTag).add(Position2D); 136 | const playerPosition = playerEntity.read(); 137 | 138 | // Creates 100 zombies at random positions with a `FollowTarget` component that 139 | // will make them follow our player. 140 | for (let i = 0; i < 100; ++i) { 141 | world.create() 142 | .add(ZombieTag) 143 | .add(Position2D, { 144 | x: Math.floor(Math.random() * 50.0) - 100.0, 145 | y: Math.floor(Math.random() * 50.0) - 100.0 146 | }) 147 | .add(FollowTarget, { target: playerPosition }) 148 | } 149 | 150 | // Runs the animation loop and execute all systems every frame. 151 | 152 | let lastTime = 0.0; 153 | function loop() { 154 | const currTime = performance.now(); 155 | const deltaTime = currTime - lastTime; 156 | lastTime = currTime; 157 | world.execute(deltaTime); 158 | requestAnimationFrame(loop); 159 | } 160 | lastTime = performance.now(); 161 | loop(); 162 | ``` 163 | 164 | ### JavaScript 165 | 166 | ```js 167 | import { 168 | ComponentData, 169 | TagComponent, 170 | NumberProp, 171 | RefProp, 172 | System, 173 | World 174 | } from 'ecstra'; 175 | 176 | /** 177 | * Components definition. 178 | */ 179 | 180 | class Position2D extends ComponentData {} 181 | Position2D.Properties = { 182 | x: NumberProp(), 183 | y: NumberProp() 184 | }; 185 | 186 | class FollowTarget extends ComponentData {} 187 | FollowTarget.Properties = { 188 | target: RefProp(), 189 | speed: NumberProp(1.0) 190 | }; 191 | 192 | class PlayerTag extends TagComponent {} 193 | class ZombieTag extends TagComponent {} 194 | 195 | /** 196 | * Systems definition. 197 | */ 198 | 199 | class ZombieFollowSystem extends System { 200 | 201 | execute(delta) { 202 | this.queries.zombies.execute((entity) => { 203 | const { speed, target } = entity.read(FollowTarget); 204 | const position = entity.write(Position2D); 205 | const deltaX = target.x - position.x; 206 | const deltaY = target.y - position.y; 207 | const len = Math.sqrt(deltaX * deltaX + deltaY * deltaY); 208 | if (len >= 0.00001) { 209 | position.x += speed * delta * (deltaX / len); 210 | position.y += speed * delta * (deltaY / len); 211 | } 212 | }); 213 | } 214 | 215 | } 216 | ZombieFollowSystem.Queries = { 217 | // Select entities with all three components `ZombieTag`, `FollowTarget`, and 218 | // `Position2D`. 219 | zombies: [ZombieTag, FollowTarget, Position2D] 220 | } 221 | 222 | const world = new World().register(ZombieFollowSystem); 223 | 224 | // Creates a player entity. 225 | const playerEntity = world.create().add(PlayerTag).add(Position2D); 226 | const playerPosition = playerEntity.read(); 227 | 228 | // Creates 100 zombies at random positions with a `FollowTarget` component that 229 | // will make them follow our player. 230 | for (let i = 0; i < 100; ++i) { 231 | world.create() 232 | .add(ZombieTag) 233 | .add(Position2D, { 234 | x: Math.floor(Math.random() * 50.0) - 100.0, 235 | y: Math.floor(Math.random() * 50.0) - 100.0 236 | }) 237 | .add(FollowTarget, { target: playerPosition }) 238 | } 239 | 240 | // Runs the animation loop and execute all systems every frame. 241 | 242 | let lastTime = 0.0; 243 | function loop() { 244 | const currTime = performance.now(); 245 | const deltaTime = currTime - lastTime; 246 | lastTime = currTime; 247 | world.execute(deltaTime); 248 | requestAnimationFrame(loop); 249 | } 250 | lastTime = performance.now(); 251 | loop(); 252 | ``` 253 | 254 | ## Running Examples 255 | 256 | In order to try the examples, you need to build the library using: 257 | 258 | ```sh 259 | yarn build # Alternatively, `yarn start` to watch the files 260 | ``` 261 | 262 | You can then start the examples web server using: 263 | 264 | ```sh 265 | yarn example 266 | ``` 267 | 268 | ### TS Examples 269 | 270 | TypeScript versions of the examples are available [here](.examples/typescript). 271 | If you only want to see the example running, you can run the JS ones as they 272 | are identicial. 273 | 274 | If you want to run the TypeScript examples themselves, please build the examples 275 | first: 276 | 277 | ```sh 278 | yarn example:build # Alternatively, `yarn example:start` to watch the files 279 | ``` 280 | 281 | And then run the examples web server: 282 | 283 | ```sh 284 | yarn example 285 | ``` 286 | 287 | ## Stable Version 288 | 289 | The library is brand new and it's the perfect time for me to taylor it to match as much as possible most of the developer needs. 290 | 291 | I want to open discussion about the following topics: 292 | * Deferred creation and removal of components 293 | * Deferred creation and removal of entities 294 | * Command buffers 295 | * Query system improvement 296 | * New selector (`Modified`? `Removed`?) 297 | * Is a `StateComponent` component needed? 298 | 299 | Please feel free to reach out directly in the [Github Issues](https://github.com/DavidPeicho/ecstra/issues) or contact me on [Twitter](https://twitter.com/DavidPeicho) to discuss those topics. 300 | 301 | ## Benchmarks 302 | 303 | Coming soon. 304 | 305 | ## Contributing 306 | 307 | For detailed information about how to contribute, please have a look at the [CONTRIBUTING.md](./CONTRIBUTING.md) guide. 308 | -------------------------------------------------------------------------------- /benchmark/benchmark-macbookpro18.json: -------------------------------------------------------------------------------- 1 | { 2 | "benchmarks": [ 3 | { 4 | "name": "Entity", 5 | "samples": [ 6 | { 7 | "name": "create / destroy entities without pool", 8 | "iterations": 25, 9 | "average": 0.38087823867797854, 10 | "memoryAverage": 283723.52 11 | }, 12 | { 13 | "name": "create / destroy entities with pool", 14 | "iterations": 25, 15 | "average": 0.20508251428604127, 16 | "memoryAverage": 112702.08 17 | }, 18 | { 19 | "name": "add tag component to entity - no pooling", 20 | "iterations": 25, 21 | "average": 0.13757079362869262, 22 | "memoryAverage": 148817.6 23 | }, 24 | { 25 | "name": "add tag component to entity - pooling", 26 | "iterations": 75, 27 | "average": 0.08282259782155355, 28 | "memoryAverage": 159372.58666666667 29 | }, 30 | { 31 | "name": "remove tag component synchronously from entity", 32 | "iterations": 75, 33 | "average": 0.08911222298940023, 34 | "memoryAverage": 142466.56 35 | }, 36 | { 37 | "name": "add data component to entity - no pooling", 38 | "iterations": 75, 39 | "average": 0.1485378122329712, 40 | "memoryAverage": 58722.346666666665 41 | }, 42 | { 43 | "name": "add data component to entity - pooling", 44 | "iterations": 75, 45 | "average": 0.09143120686213176, 46 | "memoryAverage": 127617.81333333334 47 | }, 48 | { 49 | "name": "add tag component to entity - several queries", 50 | "iterations": 25, 51 | "average": 0.15171464204788207, 52 | "memoryAverage": 8212.16 53 | } 54 | ] 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /benchmark/benchmark.ts: -------------------------------------------------------------------------------- 1 | import { performance } from 'perf_hooks'; 2 | 3 | /** 4 | * Objects gathering statis on start / stop 5 | */ 6 | class Stats { 7 | /** 8 | * Number of iterations performed, used to average values 9 | * 10 | * @hidden 11 | */ 12 | private _count: number; 13 | 14 | /** 15 | * Number of iterations performed where memory was summed, used to average 16 | * values 17 | * 18 | * @hidden 19 | */ 20 | private _memCount: number; 21 | 22 | /** 23 | * Summation of the time spent by each iteration, in ms 24 | * 25 | * @hidden 26 | */ 27 | private _elapsed: number; 28 | 29 | /** 30 | * Summation of the memory used by each iteration, in bytes 31 | * 32 | * @hidden 33 | */ 34 | private _memory: number; 35 | 36 | /** 37 | * Start time value, stored on the last call to [[Stats.start]] 38 | * 39 | * @hidden 40 | */ 41 | private _lastTimeStamp: number; 42 | 43 | /** 44 | * Start memory value, stored on the last call to [[Stats.start]] 45 | * 46 | * @hidden 47 | */ 48 | private _lastMemory: number; 49 | 50 | public constructor() { 51 | this._count = 0; 52 | this._memCount = 0; 53 | this._elapsed = 0.0; 54 | this._memory = 0.0; 55 | this._lastTimeStamp = 0.0; 56 | this._lastMemory = 0.0; 57 | } 58 | 59 | public start(): void { 60 | this._lastTimeStamp = performance.now(); 61 | this._lastMemory = process.memoryUsage().heapUsed; 62 | } 63 | 64 | public stop(): void { 65 | const time = performance.now() - this._lastTimeStamp; 66 | const memory = process.memoryUsage().heapUsed - this._lastMemory; 67 | 68 | this._elapsed += time; 69 | ++this._count; 70 | 71 | // Unfortunately, the garbage collection can automatically occurs before 72 | // the sample is done. In this case, we simply ignore this iteration memory 73 | // footprint. 74 | if (memory >= 0) { 75 | this._memory += memory; 76 | ++this._memCount; 77 | } 78 | } 79 | 80 | public get average(): number { 81 | return this._elapsed / this._count; 82 | } 83 | 84 | public get memoryAverage(): number { 85 | return this._memory / this._memCount; 86 | } 87 | } 88 | 89 | class BenchmarkGroup { 90 | private _name: string; 91 | private _samples: Sample[]; 92 | 93 | public constructor(name: string) { 94 | this._name = name; 95 | this._samples = []; 96 | } 97 | 98 | public add(sample: Sample): this { 99 | this._samples.push(sample); 100 | return this; 101 | } 102 | 103 | public get name(): string { 104 | return this._name; 105 | } 106 | 107 | public get samples(): Sample[] { 108 | return this._samples; 109 | } 110 | } 111 | 112 | /** 113 | * Create and run benchmarks 114 | */ 115 | export class Benchmark { 116 | /** 117 | * List of groups created in this benchmark 118 | * 119 | * @hidden 120 | */ 121 | private _groups: Map; 122 | 123 | /** 124 | * Called triggered when a sample is starting 125 | * 126 | * @hidden 127 | */ 128 | private _onSampleStart: (sample: Sample) => void; 129 | 130 | /** 131 | * Called triggered after a sample is completed 132 | * 133 | * @hidden 134 | */ 135 | private _onSampleComplete: (sample: BenchmarkSampleResult) => void; 136 | 137 | public constructor() { 138 | this._groups = new Map(); 139 | this._onSampleStart = () => { 140 | /* Empty. */ 141 | }; 142 | this._onSampleComplete = () => { 143 | /* Empty. */ 144 | }; 145 | } 146 | 147 | public group(name: string): BenchmarkGroup { 148 | if (!this._groups.has(name)) { 149 | this._groups.set(name, new BenchmarkGroup(name)); 150 | } 151 | return this._groups.get(name)!; 152 | } 153 | 154 | public onSampleStart(cb: (sample: Sample) => void): this { 155 | this._onSampleStart = cb; 156 | return this; 157 | } 158 | 159 | public onSampleComplete(cb: (sample: BenchmarkSampleResult) => void): this { 160 | this._onSampleComplete = cb; 161 | return this; 162 | } 163 | 164 | public run(): BenchmarkGroupResult[] { 165 | const benchmarks = [] as BenchmarkGroupResult[]; 166 | this._groups.forEach((group: BenchmarkGroup) => { 167 | this._runGroup(benchmarks, group); 168 | }); 169 | return benchmarks; 170 | } 171 | 172 | private _runGroup( 173 | results: BenchmarkGroupResult[], 174 | group: BenchmarkGroup 175 | ): void { 176 | const result = { 177 | name: group.name, 178 | samples: [] 179 | } as BenchmarkGroupResult; 180 | results.push(result); 181 | 182 | for (const sample of group.samples) { 183 | const stats = new Stats(); 184 | const name = sample.name ?? 'unnamed sample'; 185 | const iterations = sample.iterations ?? 25; 186 | this._onSampleStart({ ...sample, name, iterations }); 187 | for (let i = 0; i < iterations; ++i) { 188 | let context = {} as Context | null; 189 | if (sample.setup) { 190 | sample.setup(context as Context); 191 | } 192 | // @todo: add async. 193 | if (global.gc) { 194 | global.gc(); 195 | } 196 | stats.start(); 197 | sample.code(context as Context); 198 | stats.stop(); 199 | context = null; 200 | } 201 | const sampleResult = { 202 | name, 203 | iterations, 204 | average: stats.average, 205 | memoryAverage: stats.memoryAverage 206 | }; 207 | this._onSampleComplete(sampleResult); 208 | result.samples.push(sampleResult); 209 | } 210 | } 211 | } 212 | 213 | export interface Context { 214 | [key: string]: any; 215 | } 216 | 217 | export interface Sample { 218 | name?: string; 219 | iterations?: number; 220 | setup?: (context: Context) => void; 221 | code: (context: Context) => void; 222 | } 223 | 224 | export interface BenchmarkSampleResult { 225 | name: string; 226 | iterations: number; 227 | average: number; 228 | memoryAverage: number; 229 | } 230 | 231 | export interface BenchmarkGroupResult { 232 | name: string; 233 | samples: BenchmarkSampleResult[]; 234 | } 235 | -------------------------------------------------------------------------------- /benchmark/comparator.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chalk from 'chalk'; 4 | import { BenchmarkGroupResult, BenchmarkSampleResult } from './benchmark'; 5 | 6 | export function compare( 7 | sourceList: BenchmarkGroupResult[], 8 | actualList: BenchmarkGroupResult[], 9 | options: Partial = {} 10 | ): boolean { 11 | let { memoryTolerance = 0.025, speedTolerance = 0.025 } = options; 12 | 13 | memoryTolerance += 1.0; 14 | speedTolerance += 1.0; 15 | 16 | const actual = new Map() as Map; 17 | for (const group of actualList) { 18 | for (const sample of group.samples) { 19 | actual.set(sample.name, sample); 20 | } 21 | } 22 | 23 | let success = true; 24 | 25 | for (const group of sourceList) { 26 | log(chalk.bold(group.name)); 27 | console.log(); 28 | for (const srcSample of group.samples) { 29 | const actualSample = actual.get(srcSample.name)!; 30 | let speedDelta = 0; 31 | let memDelta = 0; 32 | if (actualSample.average > speedTolerance * srcSample.average) { 33 | speedDelta = actualSample.average - srcSample.average; 34 | } 35 | if ( 36 | actualSample.memoryAverage > 37 | memoryTolerance * srcSample.memoryAverage 38 | ) { 39 | memDelta = actualSample.memoryAverage - srcSample.memoryAverage; 40 | } 41 | const passed = speedDelta <= 0.0001 && memDelta <= 0.0001; 42 | if (passed) { 43 | log(`✅ ${chalk.gray(actualSample.name)}`, 2); 44 | } else { 45 | log(`❌ ${chalk.red(actualSample.name)}`, 2); 46 | } 47 | if (speedDelta > 0) { 48 | log(`${chalk.bold(speedDelta.toFixed(4))}ms slower`, 6); 49 | } 50 | if (memDelta > 0) { 51 | log(`${chalk.bold(memDelta.toFixed(2))}ms slower`, 6); 52 | } 53 | success = success && passed; 54 | } 55 | } 56 | 57 | return success; 58 | } 59 | 60 | function log(msg: string, spacing = 0): void { 61 | console.log(`${' '.repeat(spacing)}${msg}`); 62 | } 63 | 64 | interface ComparatorOptions { 65 | memoryTolerance: number; 66 | speedTolerance: number; 67 | } 68 | -------------------------------------------------------------------------------- /benchmark/entity.bench.ts: -------------------------------------------------------------------------------- 1 | import { Context, Benchmark } from './benchmark.js'; 2 | 3 | import { boolean, number, array, string, ref } from '../src/decorators.js'; 4 | import { ComponentData, TagComponent } from '../src/component.js'; 5 | import { World } from '../src/world.js'; 6 | import { System } from '../src/system.js'; 7 | 8 | class MyTagComponent extends TagComponent {} 9 | 10 | class MyComponentData extends ComponentData { 11 | @boolean(true) 12 | myBoolean!: boolean; 13 | @number(100) 14 | myNumber!: number; 15 | @string('hello') 16 | myString!: string; 17 | @array(['defaultStr1', 'defaultStr2']) 18 | myArray!: string[]; 19 | @ref(null) 20 | myRef!: { foo: string; bar: string } | null; 21 | } 22 | 23 | export default function (benchmark: Benchmark): void { 24 | benchmark 25 | .group('Entity') 26 | .add({ 27 | name: 'create / destroy entities without pool', 28 | setup: function (ctx: Context) { 29 | ctx.world = new World({ useManualPooling: true }); 30 | ctx.entities = new Array(100).fill(null); 31 | }, 32 | code: function (ctx: Context) { 33 | const len = ctx.entities.length; 34 | for (let i = 0; i < Math.floor(len / 3); ++i) { 35 | ctx.entities[i] = ctx.world.create(); 36 | } 37 | for (let i = 0; i < Math.floor(len / 4); ++i) { 38 | ctx.entities[i].destroy(); 39 | ctx.entities[i] = null; 40 | } 41 | for (let i = 0; i < len; ++i) { 42 | if (ctx.entities[i] === null) { 43 | ctx.entities[i] = ctx.world.create(); 44 | } 45 | } 46 | } 47 | }) 48 | .add({ 49 | name: 'create / destroy entities with pool', 50 | setup: function (ctx: Context) { 51 | ctx.world = new World({ 52 | useManualPooling: false 53 | }); 54 | ctx.entities = new Array(100); 55 | }, 56 | code: function (ctx: Context) { 57 | const len = ctx.entities.length; 58 | for (let i = 0; i < Math.floor(len / 3); ++i) { 59 | ctx.entities[i] = ctx.world.create(); 60 | } 61 | for (let i = 0; i < Math.floor(len / 4); ++i) { 62 | ctx.entities[i].destroy(); 63 | ctx.entities[i] = null; 64 | } 65 | for (let i = 0; i < len; ++i) { 66 | if (ctx.entities[i] === null) { 67 | ctx.entities[i] = ctx.world.create(); 68 | } 69 | } 70 | } 71 | }) 72 | .add({ 73 | name: 'add tag component to entity - no pooling', 74 | setup: function (ctx: Context) { 75 | ctx.world = new World({ useManualPooling: true }); 76 | ctx.world.registerComponent(MyTagComponent); 77 | ctx.entity = ctx.world.create(); 78 | }, 79 | code: function (ctx: Context) { 80 | ctx.entity.add(MyTagComponent); 81 | } 82 | }) 83 | .add({ 84 | name: 'add tag component to entity - pooling', 85 | iterations: 75, 86 | setup: function (ctx: Context) { 87 | ctx.world = new World({ useManualPooling: false }); 88 | ctx.world.registerComponent(MyTagComponent); 89 | ctx.entity = ctx.world.create(); 90 | }, 91 | code: function (ctx: Context) { 92 | ctx.entity.add(MyTagComponent); 93 | } 94 | }) 95 | .add({ 96 | name: 'remove tag component synchronously from entity', 97 | iterations: 75, 98 | setup: function (ctx: Context) { 99 | ctx.world = new World({ useManualPooling: true }); 100 | ctx.world.registerComponent(MyTagComponent); 101 | ctx.entity = ctx.world.create(); 102 | ctx.entity.add(MyTagComponent); 103 | }, 104 | code: function (ctx: Context) { 105 | ctx.entity.remove(MyTagComponent); 106 | } 107 | }) 108 | .add({ 109 | name: 'add data component to entity - no pooling', 110 | iterations: 75, 111 | setup: function (ctx: Context) { 112 | ctx.world = new World({ useManualPooling: true }); 113 | ctx.world.registerComponent(MyComponentData); 114 | ctx.entity = ctx.world.create(); 115 | }, 116 | code: function (ctx: Context) { 117 | ctx.entity.add(MyComponentData, { 118 | myBoolean: false, 119 | myNumber: 1, 120 | myString: 'Oh, Snap!', 121 | myArray: [], 122 | myRef: null 123 | }); 124 | } 125 | }) 126 | .add({ 127 | name: 'add data component to entity - pooling', 128 | iterations: 75, 129 | setup: function (ctx: Context) { 130 | ctx.world = new World({ useManualPooling: false }); 131 | ctx.world.registerComponent(MyComponentData); 132 | ctx.entity = ctx.world.create(); 133 | }, 134 | code: function (ctx: Context) { 135 | ctx.entity.add(MyComponentData, { 136 | myBoolean: false, 137 | myNumber: 1, 138 | myString: 'Oh, Snap!', 139 | myArray: [], 140 | myRef: null 141 | }); 142 | } 143 | }); 144 | 145 | (function () { 146 | class MySystem extends System { 147 | execute() { 148 | /* Emnpty. */ 149 | } 150 | } 151 | MySystem.Queries = {}; 152 | for (let i = 0; i < 100000; ++i) { 153 | MySystem.Queries[`query_${i}`] = [MyTagComponent]; 154 | } 155 | 156 | benchmark.group('Entity').add({ 157 | name: 'add tag component to entity - several queries', 158 | setup: function (ctx: Context) { 159 | ctx.world = new World({ useManualPooling: true }); 160 | ctx.world.register(MySystem); 161 | ctx.world.registerComponent(MyTagComponent); 162 | ctx.entity = ctx.world.create(); 163 | }, 164 | code: function (ctx: Context) { 165 | ctx.entity.add(MyTagComponent); 166 | } 167 | }); 168 | })(); 169 | } 170 | -------------------------------------------------------------------------------- /benchmark/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chalk from 'chalk'; 4 | 5 | import { promises as fsp } from 'fs'; 6 | 7 | import { 8 | Benchmark, 9 | BenchmarkGroupResult, 10 | BenchmarkSampleResult 11 | } from './benchmark.js'; 12 | import { compare } from './comparator.js'; 13 | import registerEntityBench from './entity.bench.js'; 14 | 15 | /** 16 | * CLI argument parsing. 17 | */ 18 | 19 | const argv = process.argv; 20 | const args = { 21 | output: null as string | null, 22 | compare: null as string | null 23 | }; 24 | 25 | const outputIndex = argv.findIndex((v) => v === '--output' || v === '-o'); 26 | if (outputIndex >= 0 && outputIndex + 1 < process.argv.length) { 27 | args.output = process.argv[outputIndex + 1]; 28 | } 29 | const compareIndex = argv.findIndex((v) => v === '--compare' || v === '-c'); 30 | if (compareIndex >= 0 && compareIndex + 1 < process.argv.length) { 31 | args.compare = process.argv[compareIndex + 1]; 32 | } 33 | 34 | /** 35 | * Main. 36 | */ 37 | 38 | const benchmark = new Benchmark(); 39 | benchmark.onSampleComplete((sample: BenchmarkSampleResult) => { 40 | const avg = sample.average; 41 | const avgStr = `${chalk.white.bold(sample.average.toFixed(4))}ms`; 42 | if (avg > 3.0) { 43 | console.log(`${chalk.red(sample.name)} ${avgStr}`); 44 | } else if (avg > 1.0) { 45 | console.log(`${chalk.yellow(sample.name)} ${avgStr}`); 46 | } else if (avg > 0.15) { 47 | console.log(`${chalk.gray(sample.name)} ${avgStr}`); 48 | } else { 49 | console.log(`${chalk.green(sample.name)} ${avgStr}`); 50 | } 51 | }); 52 | 53 | registerEntityBench(benchmark); 54 | 55 | console.log(); 56 | console.log(chalk.white.bold(`--- starting benchmark ---`)); 57 | console.log(); 58 | 59 | const benchmarks = benchmark.run(); 60 | const promises = []; 61 | 62 | if (args.output !== null) { 63 | const benchmarksJSON = JSON.stringify({ benchmarks }, null, 4); 64 | const p = fsp 65 | .writeFile(args.output, benchmarksJSON) 66 | .then(() => 0) 67 | .catch((e) => { 68 | console.error(e); 69 | return 1; 70 | }); 71 | promises.push(p); 72 | } 73 | 74 | if (args.compare !== null) { 75 | console.log(); 76 | console.log(chalk.white.bold(`--- comparing to '${args.compare}' ---`)); 77 | console.log(); 78 | const p = fsp.readFile(args.compare as string, 'utf8').then((v: string) => { 79 | const source = JSON.parse(v) as { benchmarks: BenchmarkGroupResult[] }; 80 | const success = compare(source.benchmarks, benchmarks); 81 | return success ? 0 : 1; 82 | }); 83 | promises.push(p); 84 | } 85 | 86 | Promise.all(promises).then((exitCodes: number[]) => { 87 | for (const code of exitCodes) { 88 | if (code !== 0) { 89 | process.exit(code); 90 | } 91 | } 92 | console.log(); 93 | if (args.output) { 94 | console.log(chalk.white(`benchmark results written to '${args.output}'`)); 95 | } 96 | }); 97 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavidPeicho/ecstra/8ed385b31dfd817f447fd2437e6b762cd21527c9/example.gif -------------------------------------------------------------------------------- /example/circle-boxes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ecstra example for simple drawing using the 2D Canvas API 6 | 7 | 8 | 9 | 25 | 26 | 27 | 197 | 198 |

199 | Example taken and adapted from 200 | ecsy.io 201 |

202 | 203 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /example/circle-intersections/components.js: -------------------------------------------------------------------------------- 1 | /** 2 | * File taken and adapted from 3 | * https://github.com/ecsyjs/ecsy/tree/dev/site/examples/canvas 4 | */ 5 | 6 | import { 7 | ArrayProp, 8 | ComponentData, 9 | CopyableProp, 10 | NumberProp, 11 | RefProp 12 | } from '../../dist/index.js'; 13 | 14 | export class Vector2 { 15 | constructor() { 16 | this.x = 0; 17 | this.y = 0; 18 | } 19 | 20 | set(x, y) { 21 | this.x = x; 22 | this.y = y; 23 | return this; 24 | } 25 | 26 | copy(source) { 27 | this.x = source.x; 28 | this.y = source.y; 29 | return this; 30 | } 31 | 32 | clone() { 33 | return new Vector2().set(this.x, this.y); 34 | } 35 | } 36 | 37 | export class Movement extends ComponentData {} 38 | Movement.Properties = { 39 | velocity: CopyableProp({ type: Vector2 }), 40 | acceleration: CopyableProp({ type: Vector2 }) 41 | }; 42 | 43 | export class Circle extends ComponentData {} 44 | Circle.Properties = { 45 | position: CopyableProp({ type: Vector2 }), 46 | radius: NumberProp(), 47 | velocity: CopyableProp({ type: Vector2 }), 48 | acceleration: CopyableProp({ type: Vector2 }) 49 | }; 50 | 51 | export class CanvasContext extends ComponentData {} 52 | CanvasContext.Properties = { 53 | ctx: RefProp(), 54 | width: NumberProp(), 55 | height: NumberProp() 56 | }; 57 | 58 | export class DemoSettings extends ComponentData {} 59 | DemoSettings.Properties = { 60 | speedMultiplier: NumberProp(0.001) 61 | }; 62 | 63 | export class Intersecting extends ComponentData {} 64 | Intersecting.Properties = { 65 | points: ArrayProp() 66 | }; 67 | -------------------------------------------------------------------------------- /example/circle-intersections/index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | Ecstra example for simple drawing using the 2D Canvas API 11 | 12 | 13 | 14 | 30 | 31 | 32 | 102 | 103 |

104 | Example taken and adapted from 105 | ecsy.io 106 |

107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /example/circle-intersections/systems.js: -------------------------------------------------------------------------------- 1 | /** 2 | * File taken and adapted from 3 | * https://github.com/ecsyjs/ecsy/tree/dev/site/examples/canvas 4 | */ 5 | 6 | import { System } from '../../dist/index.js'; 7 | import { 8 | CanvasContext, 9 | DemoSettings, 10 | Movement, 11 | Circle, 12 | Intersecting, 13 | } from './components.js'; 14 | 15 | export class MovementSystem extends System { 16 | execute(delta) { 17 | const context = this.queries.context.first; 18 | const canvasWidth = context.read(CanvasContext).width; 19 | const canvasHeight = context.read(CanvasContext).height; 20 | const multiplier = context.read(DemoSettings).speedMultiplier; 21 | 22 | this.queries.entities.execute((entity) => { 23 | const circle = entity.write(Circle); 24 | const movement = entity.write(Movement); 25 | 26 | circle.position.x += 27 | movement.velocity.x * movement.acceleration.x * delta * multiplier; 28 | circle.position.y += 29 | movement.velocity.y * movement.acceleration.y * delta * multiplier; 30 | 31 | if (movement.acceleration.x > 1) 32 | movement.acceleration.x -= delta * multiplier; 33 | if (movement.acceleration.y > 1) 34 | movement.acceleration.y -= delta * multiplier; 35 | if (movement.acceleration.x < 1) movement.acceleration.x = 1; 36 | if (movement.acceleration.y < 1) movement.acceleration.y = 1; 37 | if (circle.position.y + circle.radius < 0) 38 | circle.position.y = canvasHeight + circle.radius; 39 | if (circle.position.y - circle.radius > canvasHeight) 40 | circle.position.y = -circle.radius; 41 | if (circle.position.x - circle.radius > canvasWidth) 42 | circle.position.x = 0; 43 | if (circle.position.x + circle.radius < 0) 44 | circle.position.x = canvasWidth; 45 | }); 46 | } 47 | } 48 | MovementSystem.Queries = { 49 | entities: [Circle, Movement], 50 | context: [CanvasContext, DemoSettings], 51 | }; 52 | 53 | export class IntersectionSystem extends System { 54 | execute() { 55 | this.queries.entities.execute((entity) => { 56 | if (entity.has(Intersecting)) { 57 | entity.write(Intersecting).points.length = 0; 58 | } 59 | const circle = entity.read(Circle); 60 | 61 | this.queries.entities.execute((entityB) => { 62 | if (entity === entityB) { 63 | return; 64 | } 65 | const circleB = entityB.read(Circle); 66 | const intersect = intersection(circle, circleB); 67 | if (intersect !== false) { 68 | if (!entity.has(Intersecting)) { 69 | entity.add(Intersecting); 70 | } 71 | const intersectComponent = entity.write(Intersecting); 72 | intersectComponent.points.push(intersect); 73 | } 74 | }) 75 | if ( 76 | entity.has(Intersecting) && 77 | entity.read(Intersecting).points.length === 0 78 | ) { 79 | entity.remove(Intersecting); 80 | } 81 | }); 82 | } 83 | } 84 | IntersectionSystem.Queries = { 85 | entities: [Circle] 86 | }; 87 | 88 | export class Renderer extends System { 89 | execute() { 90 | const context = this.queries.context.first; 91 | const canvasComponent = context.read(CanvasContext); 92 | const ctx = canvasComponent.ctx; 93 | const canvasWidth = canvasComponent.width; 94 | const canvasHeight = canvasComponent.height; 95 | 96 | ctx.fillStyle = "black"; 97 | ctx.fillRect(0, 0, canvasWidth, canvasHeight); 98 | 99 | this.queries.circles.execute((entity) => { 100 | const circle = entity.read(Circle); 101 | ctx.beginPath(); 102 | ctx.arc( 103 | circle.position.x, 104 | circle.position.y, 105 | circle.radius, 106 | 0, 107 | 2 * Math.PI, 108 | false 109 | ); 110 | ctx.lineWidth = 1; 111 | ctx.strokeStyle = "#fff"; 112 | ctx.stroke(); 113 | }); 114 | 115 | this.queries.intersectingCircles.execute((entity) => { 116 | const intersect = entity.read(Intersecting); 117 | for (let j = 0; j < intersect.points.length; j++) { 118 | const points = intersect.points[j]; 119 | ctx.lineWidth = 2; 120 | ctx.strokeStyle = "#ff9"; 121 | ctx.fillStyle = "rgba(255, 255,255, 0.2)"; 122 | fillCircle(ctx, points[0], points[1], 8); 123 | fillCircle(ctx, points[2], points[3], 8); 124 | ctx.fillStyle = "#fff"; 125 | fillCircle(ctx, points[0], points[1], 3); 126 | fillCircle(ctx, points[2], points[3], 3); 127 | drawLine(ctx, points[0], points[1], points[2], points[3]); 128 | } 129 | }); 130 | } 131 | } 132 | 133 | Renderer.Queries = { 134 | circles: [Circle], 135 | intersectingCircles: [Intersecting], 136 | context: [CanvasContext] 137 | }; 138 | 139 | /** 140 | * Helpers 141 | */ 142 | 143 | export function random(a, b) { 144 | return Math.random() * (b - a) + a; 145 | } 146 | 147 | export function intersection(circleA, circleB) { 148 | var a, dx, dy, d, h, rx, ry; 149 | var x2, y2; 150 | 151 | // dx and dy are the vertical and horizontal distances between the circle centers. 152 | dx = circleB.position.x - circleA.position.x; 153 | dy = circleB.position.y - circleA.position.y; 154 | 155 | // Distance between the centers 156 | d = Math.sqrt(dy * dy + dx * dx); 157 | 158 | // Check for solvability 159 | if (d > circleA.radius + circleB.radius) { 160 | // No solution: circles don't intersect 161 | return false; 162 | } 163 | if (d < Math.abs(circleA.radius - circleB.radius)) { 164 | // No solution: one circle is contained in the other 165 | return false; 166 | } 167 | 168 | /* 'point 2' is the point where the line through the circle 169 | * intersection points crosses the line between the circle 170 | * centers. 171 | */ 172 | 173 | /* Determine the distance from point 0 to point 2. */ 174 | a = 175 | (circleA.radius * circleA.radius - 176 | circleB.radius * circleB.radius + 177 | d * d) / 178 | (2.0 * d); 179 | 180 | /* Determine the coordinates of point 2. */ 181 | x2 = circleA.position.x + (dx * a) / d; 182 | y2 = circleA.position.y + (dy * a) / d; 183 | 184 | /* Determine the distance from point 2 to either of the 185 | * intersection points. 186 | */ 187 | h = Math.sqrt(circleA.radius * circleA.radius - a * a); 188 | 189 | /* Now determine the offsets of the intersection points from 190 | * point 2. 191 | */ 192 | rx = -dy * (h / d); 193 | ry = dx * (h / d); 194 | 195 | /* Determine the absolute intersection points. */ 196 | var xi = x2 + rx; 197 | var xi_prime = x2 - rx; 198 | var yi = y2 + ry; 199 | var yi_prime = y2 - ry; 200 | 201 | return [xi, yi, xi_prime, yi_prime]; 202 | } 203 | 204 | export function fillCircle(ctx, x, y, radius) { 205 | ctx.beginPath(); 206 | ctx.arc(x, y, radius, 0, Math.PI * 2, false); 207 | ctx.fill(); 208 | 209 | return this; 210 | } 211 | 212 | export function drawLine(ctx, a, b, c, d) { 213 | ctx.beginPath(), ctx.moveTo(a, b), ctx.lineTo(c, d), ctx.stroke(); 214 | } 215 | -------------------------------------------------------------------------------- /example/typescript/circle-boxes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TypeScript - Ecstra example for simple drawing using the 2D Canvas API 6 | 7 | 8 | 9 | 24 | 25 | 26 |

27 | Example taken and adapted from 28 | ecsy.io 29 |

30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /example/typescript/circle-boxes/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | World, System, ComponentData, TagComponent, number, string, queries, after 3 | } from '../../../dist/index.js'; 4 | 5 | const NUM_ELEMENTS = 600; 6 | const SPEED_MULTIPLIER = 0.1; 7 | const SHAPE_SIZE = 20; 8 | const SHAPE_HALF_SIZE = SHAPE_SIZE / 2; 9 | 10 | const canvas = document.querySelector('canvas'); 11 | const ctx = canvas.getContext('2d'); 12 | let canvasWidth = canvas.width = window.innerWidth; 13 | let canvasHeight = canvas.height = window.innerHeight; 14 | 15 | /** 16 | * Components 17 | */ 18 | 19 | class Velocity extends ComponentData { 20 | @number() 21 | x!: number; 22 | @number() 23 | y!: number; 24 | } 25 | 26 | class Position extends ComponentData { 27 | @number() 28 | x!: number; 29 | @number() 30 | y!: number; 31 | } 32 | 33 | class Shape extends ComponentData { 34 | @string() 35 | primitive!: string; 36 | } 37 | 38 | class Renderable extends TagComponent {} 39 | 40 | /** 41 | * Systems 42 | */ 43 | 44 | @queries({ 45 | // The `moving` query looks for entities with both the `Velocity` and 46 | // `Position` components. 47 | moving: [ Velocity, Position ] 48 | }) 49 | class MovableSystem extends System { 50 | public execute(delta) { 51 | this.queries.moving.execute(entity => { 52 | const velocity = entity.read(Velocity); 53 | const position = entity.write(Position); 54 | position.x += velocity.x * delta; 55 | position.y += velocity.y * delta; 56 | if (position.x > canvasWidth + SHAPE_HALF_SIZE) position.x = - SHAPE_HALF_SIZE; 57 | if (position.x < - SHAPE_HALF_SIZE) position.x = canvasWidth + SHAPE_HALF_SIZE; 58 | if (position.y > canvasHeight + SHAPE_HALF_SIZE) position.y = - SHAPE_HALF_SIZE; 59 | if (position.y < - SHAPE_HALF_SIZE) position.y = canvasHeight + SHAPE_HALF_SIZE; 60 | }); 61 | } 62 | } 63 | 64 | @after([MovableSystem]) 65 | class RendererSystem extends System { 66 | 67 | // This is equivalent to using the `query` decorator. 68 | public static Queries = { 69 | // The `renderables` query looks for entities with both the 70 | // `Renderable` and `Shape` components. 71 | renderables: [Renderable, Shape] 72 | }; 73 | 74 | public execute(): void { 75 | ctx.globalAlpha = 1; 76 | ctx.fillStyle = "#ffffff"; 77 | ctx.fillRect(0, 0, canvasWidth, canvasHeight); 78 | //ctx.globalAlpha = 0.6; 79 | 80 | // Iterate through all the entities on the query 81 | this.queries.renderables.execute(entity => { 82 | const shape = entity.read(Shape); 83 | const position = entity.read(Position); 84 | if (shape.primitive === 'box') { 85 | this.drawBox(position); 86 | } else { 87 | this.drawCircle(position); 88 | } 89 | }); 90 | } 91 | 92 | drawCircle(position) { 93 | ctx.fillStyle = "#888"; 94 | ctx.beginPath(); 95 | ctx.arc(position.x, position.y, SHAPE_HALF_SIZE, 0, 2 * Math.PI, false); 96 | ctx.fill(); 97 | ctx.lineWidth = 1; 98 | ctx.strokeStyle = "#222"; 99 | ctx.stroke(); 100 | } 101 | 102 | drawBox(position) { 103 | ctx.beginPath(); 104 | ctx.rect(position.x - SHAPE_HALF_SIZE, position.y - SHAPE_HALF_SIZE, SHAPE_SIZE, SHAPE_SIZE); 105 | ctx.fillStyle= "#f28d89"; 106 | ctx.fill(); 107 | ctx.lineWidth = 1; 108 | ctx.strokeStyle = "#800904"; 109 | ctx.stroke(); 110 | } 111 | } 112 | 113 | window.addEventListener( 'resize', () => { 114 | canvasWidth = canvas.width = window.innerWidth 115 | canvasHeight = canvas.height = window.innerHeight; 116 | }, false ); 117 | 118 | // Step 1 - Create the world that will host our entities. 119 | const world = new World() 120 | .register(MovableSystem) 121 | .register(RendererSystem); 122 | 123 | // Step 2 - Create entities with random velocity / positions / shapes 124 | for (let i = 0; i < NUM_ELEMENTS; i++) { 125 | world 126 | .create() 127 | .add(Velocity, getRandomVelocity()) 128 | .add(Shape, getRandomShape()) 129 | .add(Shape, getRandomShape()) 130 | .add(Position, getRandomPosition()) 131 | .add(Renderable) 132 | } 133 | 134 | // Step 3 - Run all the systems and let the ECS do its job! 135 | let lastTime = 0; 136 | function run() { 137 | // Compute delta and elapsed time. 138 | const time = performance.now(); 139 | const delta = time - lastTime; 140 | 141 | // Runs all the systems. 142 | world.execute(delta); 143 | 144 | lastTime = time; 145 | requestAnimationFrame(run); 146 | } 147 | lastTime = performance.now(); 148 | run(); 149 | 150 | /** 151 | * Set of helpers for component instanciation 152 | */ 153 | 154 | function getRandomVelocity(): { x: number, y: number } { 155 | return { 156 | x: SPEED_MULTIPLIER * (2 * Math.random() - 1), 157 | y: SPEED_MULTIPLIER * (2 * Math.random() - 1) 158 | }; 159 | } 160 | function getRandomPosition(): { x: number, y: number } { 161 | return { 162 | x: Math.random() * canvasWidth, 163 | y: Math.random() * canvasHeight 164 | }; 165 | } 166 | function getRandomShape(): { primitive: string } { 167 | return { 168 | primitive: Math.random() >= 0.5 ? 'circle' : 'box' 169 | }; 170 | } 171 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecstra", 3 | "version": "0.0.2", 4 | "description": "Fast & Flexible EntityComponentSystem (ECS) for JavaScript & TypeScript", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/DavidPeicho/ecstra.git" 8 | }, 9 | "author": "David Peicho ", 10 | "license": "MIT", 11 | "keywords": [ 12 | "ecs", 13 | "entity component system", 14 | "game" 15 | ], 16 | "type": "module", 17 | "main": "./umd/ecstra.js", 18 | "module": "./index.js", 19 | "types": "./index.d.ts", 20 | "scripts": { 21 | "build": "npm run copy:to:dist && npm run build:ts && npm run build:umd", 22 | "build:ts": "tsc", 23 | "build:umd": "rollup -c", 24 | "start": "npm run copy:to:dist && tsc --watch", 25 | "example": "http-server ./ -c-1 -p 8080 --cors ./example", 26 | "example:build": "tsc ./example/typescript/**/*.ts --target ES6 --experimentalDecorators && npm run example", 27 | "example:start": "tsc ./example/typescript/**/*.ts --target ES6 --experimentalDecorators -w && npm run example", 28 | "benchmark": "node --expose-gc --loader ts-node/esm ./benchmark/index.ts", 29 | "lint": "eslint ./src/**/*.ts", 30 | "test": "ava", 31 | "test:coverage": "c8 check-coverage --lines 90 --functions 90 --branches 90 npm test", 32 | "coverage": "c8 npm test", 33 | "pretty": "prettier ./src/ ./test ./benchmark --write", 34 | "copy:to:dist": "node --experimental-modules ./scripts/copy-to-dist.js" 35 | }, 36 | "devDependencies": { 37 | "@ava/typescript": "^1.1.1", 38 | "@rollup/plugin-node-resolve": "^11.1.1", 39 | "@rollup/plugin-replace": "^2.3.4", 40 | "@types/node": "^14.14.28", 41 | "@typescript-eslint/eslint-plugin": "^4.13.0", 42 | "@typescript-eslint/parser": "^4.13.0", 43 | "ava": "^3.15.0", 44 | "c8": "^7.7.1", 45 | "chalk": "^4.1.0", 46 | "eslint": "^7.17.0", 47 | "eslint-config-prettier": "^7.1.0", 48 | "eslint-plugin-prettier": "^3.3.1", 49 | "http-server": "^0.12.3", 50 | "husky": "^4.3.8", 51 | "prettier": "^2.2.1", 52 | "rollup": "^2.38.5", 53 | "rollup-plugin-terser": "^7.0.2", 54 | "ts-node": "^9.1.1", 55 | "typescript": "^4.1.3" 56 | }, 57 | "ava": { 58 | "extensions": { 59 | "ts": "module" 60 | }, 61 | "nonSemVerExperiments": { 62 | "configurableModuleFormat": true 63 | }, 64 | "nodeArguments": [ 65 | "--loader=ts-node/esm" 66 | ], 67 | "files": [ 68 | "test/**/*.test.ts" 69 | ] 70 | }, 71 | "husky": { 72 | "hooks": { 73 | "pre-commit": "npm run pretty" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import replace from "@rollup/plugin-replace"; 2 | import { terser } from "rollup-plugin-terser"; 3 | 4 | export default [ 5 | { 6 | input: 'dist/index.js', 7 | plugins: [ 8 | replace({ 9 | 'process.env.NODE_ENV': JSON.stringify('development'), 10 | delimiters: ['', ''] 11 | }) 12 | ], 13 | output: [ 14 | { 15 | format: 'umd', 16 | name: 'FECS', 17 | noConflict: true, 18 | file: 'dist/umd/ecstra.js' 19 | } 20 | ] 21 | }, 22 | { 23 | input: "dist/index.js", 24 | plugins: [ 25 | replace({ 26 | 'process.env.NODE_ENV': JSON.stringify('production'), 27 | delimiters: ['', ''] 28 | }), 29 | terser() 30 | ], 31 | output: [ 32 | { 33 | format: 'umd', 34 | name: 'FECS', 35 | noConflict: true, 36 | file: 'dist/umd/ecstra.min.js' 37 | } 38 | ] 39 | } 40 | ]; 41 | -------------------------------------------------------------------------------- /scripts/copy-to-dist.js: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, promises } from 'fs'; 2 | import { resolve } from 'path'; 3 | 4 | const COPY_REL_FILES = [ 'README.md', 'LICENSE', 'package.json' ]; 5 | const DIST_REL_PATH = './dist'; 6 | 7 | // Creates the build directory if it doesn't exist. 8 | if (!existsSync(DIST_REL_PATH)) { 9 | mkdirSync(DIST_REL_PATH); 10 | } 11 | // Copies static files to build folder. 12 | for (const file of COPY_REL_FILES) { 13 | promises.copyFile(file, resolve(DIST_REL_PATH, file)); 14 | } 15 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf dist 4 | 5 | echo "[ PUBLISH ]: Linting package..." 6 | npm run lint 7 | 8 | echo "[ PUBLISH ]: Running tests..." 9 | npm run test 10 | 11 | echo "[ PUBLISH ]: Generating build files..." 12 | npm run build 13 | 14 | # Switch to public profile to ensure publisher is using public npmrc config 15 | npmrc public-profile 16 | 17 | npm publish ./dist 18 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | import { Property } from './property.js'; 2 | import { Constructor, DataComponentClass, Option, PropertiesOf } from './types'; 3 | 4 | export enum ComponentState { 5 | None = 0, 6 | Added = 1, 7 | Ready = 2, 8 | Removed = 3 9 | } 10 | 11 | /** 12 | * Base class for a component. 13 | * 14 | * Components are attached to entity and are used as a source of inputs. 15 | * Components should only contain data and no logic 16 | * 17 | * @category components 18 | */ 19 | export abstract class Component { 20 | /** Name of the component class */ 21 | public static readonly Name?: string; 22 | /** `true` if the object instance derives from [[Component]] */ 23 | public readonly isComponent!: true; 24 | 25 | /** 26 | * @hidden 27 | */ 28 | public _state: ComponentState; 29 | 30 | /** 31 | * @hidden 32 | */ 33 | public _pooled: boolean; 34 | 35 | public constructor() { 36 | Object.defineProperty(this, 'isComponent', { value: true }); 37 | this._state = ComponentState.None; 38 | this._pooled = false; 39 | } 40 | 41 | /** 42 | * This is useless for now. 43 | */ 44 | get state(): ComponentState { 45 | return this._state; 46 | } 47 | 48 | /** 49 | * Returns `true` if the component instance has been created from a component 50 | * pool. `false` otherwise 51 | */ 52 | get pooled(): boolean { 53 | return this._pooled; 54 | } 55 | } 56 | 57 | /** 58 | * Component holding data feeding entity: number, string, reference, etc... 59 | * 60 | * This must be the most common component type you are going to need. 61 | * 62 | * ComponentData list static roperties they expect to deal with. Using static 63 | * properties allow seemless creation of component classes, and simplify the 64 | * process of copy and re-initialization of components. 65 | * 66 | * If you wish to create a component holding data, but without using static 67 | * properties, please create your own class deriving from [[Component]]. 68 | * 69 | * ## Usage 70 | * 71 | * ```js 72 | * class MyComponent extends ComponentData {} 73 | * MyComponent.Properties = { 74 | * bool: BooleanProp(true), 75 | * number: NumberProp(100) 76 | * }; 77 | * ``` 78 | * 79 | * ## Decorators 80 | * 81 | * ```ts 82 | * class TestComponentDecorator extends ComponentData { 83 | * @boolean(true) 84 | * bool!: boolean; 85 | * 86 | * @number(100) 87 | * number!: number; 88 | * } 89 | * ``` 90 | * 91 | * @category components 92 | */ 93 | export class ComponentData extends Component { 94 | /** 95 | * Component schema. 96 | * 97 | * This should list all the data the component will host 98 | */ 99 | public static readonly Properties?: Properties; 100 | 101 | /** `true` if the instance derives from the [[ComponentData]] class */ 102 | public readonly isDataComponent!: true; 103 | 104 | public constructor() { 105 | super(); 106 | 107 | Object.defineProperty(this, 'isDataComponent', { value: true }); 108 | 109 | // Copy default values for properties found in the inheritance hierarchy. 110 | let Class = this.constructor as DataComponentClass; 111 | do { 112 | const staticProps = Class.Properties; 113 | if (!staticProps) { 114 | continue; 115 | } 116 | for (const name in staticProps) { 117 | const prop = staticProps[name]; 118 | this[name as keyof this] = prop.cloneDefault(); 119 | } 120 | } while ( 121 | !!(Class = Object.getPrototypeOf(Class)) && 122 | Class !== ComponentData 123 | ); 124 | } 125 | 126 | /** 127 | * Copies the `source` argument into this instance. The `source` 128 | * 129 | * @param source - Source data to copy into `this` instance. Can be either 130 | * another component of the same type, or a literal object containing the 131 | * same properties as the component (mapped to the same types) 132 | * 133 | * @return This instance 134 | */ 135 | public copy(source: PropertiesOf): this { 136 | const Class = this.constructor as DataComponentClass; 137 | for (const name in source) { 138 | const prop = findProperty(Class, name); 139 | if (prop) { 140 | const value = source[name as keyof PropertiesOf]; 141 | this[name as keyof this] = prop.copy(this[name as keyof this], value); 142 | } 143 | } 144 | return this; 145 | } 146 | 147 | /** 148 | * Returns a new instance set to the same values as `this` 149 | * 150 | * @returns A clone of `this` instance 151 | */ 152 | public clone(): this { 153 | return new (this.constructor as Constructor)().copy(this); 154 | } 155 | 156 | /** 157 | * Initiliazes the component with its default properties, overriden by 158 | * the `source` 159 | * 160 | * @param source - Source object to feed the component 161 | * @return This instance 162 | */ 163 | public init(source: PropertiesOf): this { 164 | let Class = this.constructor as DataComponentClass; 165 | do { 166 | // Copy properties found in the inheritance hierarchy. If the property 167 | // isn't found in the source, the default value is used. 168 | const staticProps = Class.Properties; 169 | if (!staticProps) { 170 | continue; 171 | } 172 | for (const name in staticProps) { 173 | const prop = staticProps[name]; 174 | if (source.hasOwnProperty(name)) { 175 | const value = source[name as keyof PropertiesOf]; 176 | this[name as keyof this] = prop.copy(this[name as keyof this], value); 177 | } else { 178 | this[name as keyof this] = prop.copyDefault(this[name as keyof this]); 179 | } 180 | } 181 | } while ( 182 | !!(Class = Object.getPrototypeOf(Class)) && 183 | Class !== ComponentData 184 | ); 185 | 186 | return this; 187 | } 188 | } 189 | 190 | // @todo: up to one component per world on a dummy entity. 191 | export class SingletonComponent extends ComponentData { 192 | public readonly isSingletonComponent!: true; 193 | public constructor() { 194 | super(); 195 | Object.defineProperty(this, 'isSingletonComponent', { value: true }); 196 | } 197 | } 198 | 199 | /** 200 | * Component used only to tag entities. [[TagComponent]] do not hold any data 201 | * 202 | * @category components 203 | */ 204 | export class TagComponent extends Component { 205 | /** `true` if the instance derives from the [[TagComponent]] class */ 206 | public readonly isTagComponent!: true; 207 | 208 | public constructor() { 209 | super(); 210 | Object.defineProperty(this, 'isTagComponent', { value: true }); 211 | } 212 | } 213 | 214 | function findProperty( 215 | Class: DataComponentClass, 216 | name: string 217 | ): Option> { 218 | do { 219 | if (Class.Properties && name in Class.Properties) { 220 | return Class.Properties[name]; 221 | } 222 | } while (!!(Class = Object.getPrototypeOf(Class)) && Class !== ComponentData); 223 | return undefined; 224 | } 225 | 226 | export interface Properties { 227 | [key: string]: Property; 228 | } 229 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const process = { 2 | env: { 3 | NODE_ENV: 'production' 4 | } 5 | }; 6 | const nodeProcess = (globalThis as any).process; 7 | if (nodeProcess && nodeProcess.env && nodeProcess.env.NODE_ENV != '') { 8 | const thisEnv = process.env; 9 | thisEnv.NODE_ENV = nodeProcess.env.NODE_ENV; 10 | } 11 | -------------------------------------------------------------------------------- /src/data/observer.ts: -------------------------------------------------------------------------------- 1 | import { Nullable } from '../types'; 2 | 3 | export class Observer { 4 | public id: Nullable = null; 5 | public autoRemove = false; 6 | public callback: ObserverCallback = () => { 7 | /** Empty. */ 8 | }; 9 | 10 | public constructor(cb?: ObserverCallback) { 11 | if (cb) { 12 | this.callback = cb; 13 | } 14 | } 15 | } 16 | 17 | export class Observable { 18 | /** @hidden */ 19 | private _observers: Observer[] = []; 20 | 21 | public observe(observer: Observer): this { 22 | this._observers.push(observer); 23 | return this; 24 | } 25 | 26 | public unobserve(observer: Observer): this { 27 | const index = this._observers.indexOf(observer); 28 | if (index >= 0) { 29 | this._observers.splice(index, 1); 30 | } 31 | return this; 32 | } 33 | 34 | public unobserveFn(cb: ObserverCallback): this { 35 | const observers = this._observers; 36 | for (let i = 0; i < observers.length; ++i) { 37 | if (observers[i].callback === cb) { 38 | observers.splice(i, 1); 39 | return this; 40 | } 41 | } 42 | return this; 43 | } 44 | 45 | public unobserveId(id: string): this { 46 | const observers = this._observers; 47 | for (let i = observers.length - 1; i >= 0; --i) { 48 | if (observers[i].id === id) { 49 | observers.splice(i, 1); 50 | } 51 | } 52 | return this; 53 | } 54 | 55 | public notify(data: T): void { 56 | const observers = this._observers; 57 | for (const o of observers) { 58 | o.callback(data); 59 | } 60 | } 61 | 62 | public get count(): number { 63 | return this._observers.length; 64 | } 65 | } 66 | 67 | type ObserverCallback = (data: T) => void; 68 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | import { ComponentData } from './component.js'; 2 | import { 3 | ArrayProp, 4 | BooleanProp, 5 | CopyableProp, 6 | CopyClonableOptions, 7 | CopyClonableType, 8 | NumberProp, 9 | Property, 10 | RefProp, 11 | StringProp 12 | } from './property.js'; 13 | import { QueryComponents } from './query.js'; 14 | import { 15 | DataComponentClass, 16 | Nullable, 17 | PropertyClass, 18 | SystemClass, 19 | SystemGroupClass 20 | } from './types.js'; 21 | 22 | /** Properties. */ 23 | 24 | /** 25 | * Assigns the given Fecs property to a decorated class property 26 | * 27 | * @param property - Fecs property object to assign to the decorated class 28 | * property 29 | * @return A generic property decorator 30 | * 31 | * @hidden 32 | */ 33 | function setupProperty(property: Property) { 34 | return function (target: ComponentData, key: string) { 35 | const constructor = target.constructor as DataComponentClass; 36 | if (!constructor.Properties) { 37 | constructor.Properties = {}; 38 | } 39 | constructor.Properties[key] = property; 40 | }; 41 | } 42 | 43 | /** 44 | * Builds a Fecs property and assign it to a decorated class property. 45 | * 46 | * **Notes**: This function is just a healper to construct class with a generic 47 | * object 48 | * 49 | * @param {(PropertyClass | PropertyDecoratorOptions)} options - Option 50 | * object to use to setup the property 51 | * @return A generic property decorator 52 | */ 53 | export function property( 54 | options: PropertyClass | PropertyDecoratorOptions 55 | ) { 56 | return function (_: ComponentData, key: string) { 57 | if (typeof options === 'function') { 58 | setupProperty(new (options as PropertyClass)()); 59 | } else { 60 | const Class = options.type; 61 | setupProperty(new Class(options)); 62 | } 63 | }; 64 | } 65 | 66 | /** 67 | * Decorator for a boolean component property 68 | * 69 | * ```ts 70 | * class MyComponent extends ComponentData { 71 | * boolean(true) 72 | * myBoolean!: boolean; 73 | * } 74 | * ``` 75 | * 76 | * @param defaultValue - Default value of the property, used when 77 | * the component is initialized 78 | * @return Decorator for a property storing a boolean value 79 | */ 80 | export function boolean(defaultValue?: boolean) { 81 | return setupProperty(BooleanProp(defaultValue)); 82 | } 83 | 84 | /** 85 | * Decorator for a numeric component property 86 | * 87 | * ## Examples 88 | * 89 | * ```ts 90 | * class MyComponent extends ComponentData { 91 | * number(100) 92 | * myNumber!: boolean; 93 | * } 94 | * ``` 95 | * 96 | * @param defaultValue - Default value of the property, used when 97 | * the component is initialized 98 | * @return Decorator for a property storing a number value 99 | */ 100 | export function number(defaultValue?: number) { 101 | return setupProperty(NumberProp(defaultValue)); 102 | } 103 | 104 | /** 105 | * Decorator for a string component property 106 | * 107 | * ```ts 108 | * class MyComponent extends ComponentData { 109 | * string('This is the default value!') 110 | * myString!: boolean; 111 | * } 112 | * ``` 113 | * 114 | * @param defaultValue - Default value of the property, used when 115 | * the component is initialized 116 | * @return Decorator for a property storing a string value 117 | */ 118 | export function string(defaultValue?: string) { 119 | return setupProperty(StringProp(defaultValue)); 120 | } 121 | 122 | /** 123 | * Decorator for an array component property 124 | * 125 | * ```ts 126 | * class MyComponent extends ComponentData { 127 | * array([ 'This', 'is', 'the', 'default', 'value']) 128 | * myArray!: string[]; 129 | * } 130 | * ``` 131 | * 132 | * @param defaultValue - Default value of the property, used when 133 | * the component is initialized 134 | * @return Decorator for a property storing an array value 135 | */ 136 | export function array(defaultValue?: T[]) { 137 | return setupProperty(ArrayProp(defaultValue)); 138 | } 139 | 140 | /** 141 | * Decorator for a reference component property 142 | * 143 | * ```ts 144 | * class MyComponent extends ComponentData { 145 | * ref(null) 146 | * myRef!: Vector2 | null; 147 | * } 148 | * ``` 149 | * 150 | * @param defaultValue - Default value of the property, used when 151 | * the component is initialized 152 | * @return Decorator for a property storing a reference value 153 | */ 154 | export function ref(defaultValue?: Nullable) { 155 | return setupProperty(RefProp(defaultValue)); 156 | } 157 | 158 | /** 159 | * Decorator for a reference component property 160 | * 161 | * ## Examples 162 | * 163 | * ```ts 164 | * class Vector2() { 165 | * static Zero = new Vector2(0, 0); 166 | * constructor(x: number, y: number) { 167 | * this.x = x; 168 | * this.y = y; 169 | * } 170 | * copy(source: this): this { 171 | * this.x = source.x; 172 | * this.y = source.y; 173 | * return this; 174 | * } 175 | * clone(): Vector2 { 176 | * return new (this.constructor)().copy(this); 177 | * } 178 | * } 179 | * 180 | * class MyComponent extends ComponentData { 181 | * copyable({ type: Vector2, default: new Vector2(0, 0) }) 182 | * myCopyable!: Vector2; 183 | * } 184 | * ``` 185 | * 186 | * @param defaultValue - Default value of the property, used when 187 | * the component is initialized 188 | * @return Decorator for a property storing a 'copyable' value 189 | */ 190 | export function copyable( 191 | opts: CopyClonableOptions 192 | ) { 193 | return setupProperty(CopyableProp(opts)); 194 | } 195 | 196 | export interface PropertyDecoratorOptions { 197 | type: PropertyClass; 198 | default?: T; 199 | } 200 | 201 | /** Systems. */ 202 | 203 | /** 204 | * Decorator to specifiy system classes that should be executed **after** 205 | * a system 206 | * 207 | * ## Examples 208 | * 209 | * @before([ SystemThatRunsAfter, ... ]) 210 | * class MySystem extends System {} 211 | * 212 | * @param Classes - List of classes that should be executed 213 | * **after** 214 | * @return Decorator for a system class declaration 215 | */ 216 | export function before(Classes: SystemClass[]) { 217 | return function (constructor: SystemClass) { 218 | constructor.UpdateBefore = Classes; 219 | }; 220 | } 221 | 222 | /** 223 | * Decorator to specifiy system classes that should be executed **before** 224 | * a system 225 | * 226 | * ## Examples 227 | * 228 | * @after([ SystemThatRunsBefore, ... ]) 229 | * class MySystem extends System {} 230 | * 231 | * @param Classes - List of classes that should be executed 232 | * **before** 233 | * @return Decorator for a system class declaration 234 | */ 235 | export function after(Classes: SystemClass[]) { 236 | // @todo: merge with existing hierarchy. 237 | return function (constructor: SystemClass) { 238 | constructor.UpdateAfter = Classes; 239 | }; 240 | } 241 | 242 | /** 243 | * Decorator to specifiy the group a system should belong to 244 | * 245 | * ## Examples 246 | * 247 | * class MyGroup extends SystemGroup {} 248 | * 249 | * @group(MyGroup) 250 | * class MySystem extends System {} 251 | * 252 | * @param Class - Group class in which this system should be added 253 | * @return Decorator for a system class declaration 254 | */ 255 | export function group(Class: SystemGroupClass) { 256 | // @todo: merge with existing hierarchy. 257 | return function (constructor: SystemClass) { 258 | constructor.Group = Class; 259 | }; 260 | } 261 | 262 | /** 263 | * Decorator to specifiy the static queries of a system. 264 | * 265 | * 266 | * ## Examples 267 | * 268 | * class MyGroup extends SystemGroup {} 269 | * 270 | * @queries({ 271 | * queryA: [ ComponentX, ... ] 272 | * }) 273 | * class MySystem extends System {} 274 | * 275 | * @param Class - Group class in which this system should be added 276 | * @return Decorator for a system class declaration 277 | */ 278 | export function queries(QueriesDesc: { [key: string]: QueryComponents }) { 279 | return function (constructor: SystemClass) { 280 | constructor.Queries = QueriesDesc; 281 | }; 282 | } 283 | -------------------------------------------------------------------------------- /src/entity.ts: -------------------------------------------------------------------------------- 1 | import { Archetype } from './internals/archetype.js'; 2 | import { Component } from './component.js'; 3 | import { World } from './world.js'; 4 | import { ComponentClass, Nullable, Option, PropertiesOf } from './types'; 5 | import { createUUID } from './utils.js'; 6 | 7 | /** 8 | * Entities are actors in the [[World]]. Entities hold data via [[Component]] 9 | * that get queried/transformed by [[System]]. 10 | * 11 | * Entities can represent anything: 12 | * * Player 13 | * * Ennemies 14 | * * Decors 15 | * * Spawner 16 | * * etc... 17 | * 18 | * @category entity 19 | */ 20 | export class Entity { 21 | /** 22 | * Name of the entity. This should be sepcified by a user, and is used to 23 | * retrieve entities 24 | */ 25 | public name: Nullable; 26 | 27 | /** 28 | * @hidden 29 | */ 30 | public _pooled: boolean; 31 | 32 | /** 33 | * @hidden 34 | */ 35 | public readonly _components: Map; 36 | 37 | /** 38 | * @hidden 39 | */ 40 | public readonly _pendingComponents: Component[]; 41 | 42 | /** 43 | * @hidden 44 | */ 45 | public _archetype: Nullable>; 46 | 47 | /** 48 | * @hidden 49 | */ 50 | public _indexInArchetype: number; 51 | 52 | /** 53 | * @hidden 54 | */ 55 | private _world!: World; 56 | 57 | /** 58 | * @hidden 59 | */ 60 | private readonly _id!: string; 61 | 62 | public constructor(name?: string) { 63 | this.name = name ?? null; 64 | this._id = createUUID(); 65 | this._components = new Map(); 66 | this._pendingComponents = []; 67 | this._archetype = null; 68 | this._indexInArchetype = -1; 69 | this._pooled = false; 70 | } 71 | 72 | /** 73 | * Destroys the entity and removes it from the world. 74 | * 75 | * **Note**: when destroyed, an entity can be re-used 76 | */ 77 | public destroy(): void { 78 | this._world._destroyEntityRequest(this); 79 | } 80 | 81 | /** 82 | * Adds a component of type `Class` to this entity 83 | * 84 | * @param Class - Class of the component to add 85 | * @param opts - Options object to initialize the component 86 | * @return This instance 87 | */ 88 | public add( 89 | Class: ComponentClass, 90 | opts?: PropertiesOf 91 | ): this { 92 | this._world._addComponentRequest(this, Class, opts); 93 | return this; 94 | } 95 | 96 | /** 97 | * Removes the component of type `Class` from this entity 98 | * 99 | * @param Class - Class of the component to remove 100 | * @return This instance 101 | */ 102 | public remove(Class: ComponentClass): this { 103 | this._world._removeComponentRequest(this, Class); 104 | return this; 105 | } 106 | 107 | /** 108 | * Returns the instance of the component of type `Class` as **read-only**. 109 | * 110 | * **Note**: right now, read-only and read-write mode are similar and do not 111 | * do anything special 112 | * 113 | * @param Class - Class of the component to retrieve 114 | * @return The component instance if found, `undefined` otherwise 115 | */ 116 | public read(Class: ComponentClass): Option { 117 | return this._components.get(Class) as Option; 118 | } 119 | 120 | /** 121 | * Returns the instance of the component of type `Class` for **read-write**. 122 | * 123 | * **Note**: right now, read-only and read-write mode are similar and do not 124 | * do anything special 125 | * 126 | * @param Class - Class of the component to retrieve 127 | * @return The component instance if found, `undefined` otherwise 128 | */ 129 | public write(Class: ComponentClass): Option { 130 | // @todo: retrieve component in write mode 131 | return this._components.get(Class) as Option; 132 | } 133 | 134 | /** 135 | * Returns `true` if a component instance of type `Class` is found 136 | * 137 | * @param Class - Class of the component to check 138 | * @return `true` if the component instance exists, `false` otherwise 139 | */ 140 | public has(Class: ComponentClass): boolean { 141 | return this._components.has(Class); 142 | } 143 | 144 | /** 145 | * Returns the identifier (UUID) of this entity 146 | */ 147 | public get id(): string { 148 | return this._id; 149 | } 150 | 151 | /** 152 | * Returns `true` is this entity is empty, i.e., has no components 153 | */ 154 | public get isEmpty(): boolean { 155 | return this._components.size === 0; 156 | } 157 | 158 | /** 159 | * Returns an array of all component classes stored in this entity 160 | * 161 | * **Note**: do not overuse this method, it allocates a new array each time 162 | */ 163 | public get componentClasses(): ComponentClass[] { 164 | return Array.from(this._components.keys()); 165 | } 166 | 167 | /** 168 | * Returns a reference to the archetype in which the entity is added 169 | */ 170 | public get archetype(): Nullable> { 171 | return this._archetype; 172 | } 173 | 174 | /** Returns `true` if this instance has been created by a pool, `false` 175 | * otherwise 176 | */ 177 | public get pooled(): boolean { 178 | return this._pooled; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Component, 3 | ComponentData, 4 | TagComponent, 5 | SingletonComponent 6 | } from './component.js'; 7 | export { Entity } from './entity.js'; 8 | export { Query, Not } from './query.js'; 9 | export { System } from './system.js'; 10 | export { SystemGroup } from './system-group.js'; 11 | export { World } from './world.js'; 12 | 13 | export { ComponentRegisterOptions } from './internals/component-manager'; 14 | 15 | /** Misc */ 16 | 17 | export { DefaultPool, ObjectPool } from './pool.js'; 18 | 19 | /** Properties. */ 20 | 21 | export * from './property.js'; 22 | 23 | /** Decorators. */ 24 | 25 | // @todo: maybe it shouldn't be exported by default? 26 | export * from './decorators.js'; 27 | 28 | /** Types. */ 29 | export * from './types.js'; 30 | -------------------------------------------------------------------------------- /src/internals/archetype.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from '../data/observer.js'; 2 | import { Entity } from '../entity.js'; 3 | import { ComponentClass } from '../types'; 4 | 5 | export class Archetype { 6 | public readonly entities: E[]; 7 | 8 | private readonly _components: Set; 9 | private readonly _hash: string; 10 | private readonly _onEntityAdded: Observable; 11 | private readonly _onEntityRemoved: Observable; 12 | 13 | public constructor(components: ComponentClass[], hash: string) { 14 | this.entities = []; 15 | this._hash = hash; 16 | this._components = new Set(components); 17 | this._onEntityAdded = new Observable(); 18 | this._onEntityRemoved = new Observable(); 19 | } 20 | 21 | public add(entity: E): void { 22 | entity._indexInArchetype = this.entities.length; 23 | entity._archetype = this; 24 | this.entities.push(entity); 25 | this._onEntityAdded.notify(entity); 26 | } 27 | 28 | public remove(entity: E): void { 29 | const entities = this.entities; 30 | // Move last entity to removed location. 31 | if (entities.length > 1) { 32 | const last = entities[entities.length - 1]; 33 | last._indexInArchetype = entity._indexInArchetype; 34 | entities[entity._indexInArchetype] = last; 35 | entities.pop(); 36 | } else { 37 | entities.length = 0; 38 | } 39 | entity._archetype = null; 40 | entity._indexInArchetype = -1; 41 | this._onEntityRemoved.notify(entity); 42 | } 43 | 44 | public hasEntity(entity: E): boolean { 45 | return this.entities.indexOf(entity) >= 0; 46 | } 47 | 48 | public get hash(): string { 49 | return this._hash; 50 | } 51 | 52 | public get components(): Set { 53 | return this._components; 54 | } 55 | 56 | public get empty(): boolean { 57 | return this.entities.length === 0; 58 | } 59 | 60 | public get onEntityAdded(): Observable { 61 | return this._onEntityAdded; 62 | } 63 | 64 | public get onEntityRemoved(): Observable { 65 | return this._onEntityRemoved; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/internals/component-manager.ts: -------------------------------------------------------------------------------- 1 | import { Component, ComponentState, ComponentData } from '../component.js'; 2 | import { Entity } from '../entity.js'; 3 | import { ObjectPool } from '../pool.js'; 4 | import { World } from '../world.js'; 5 | import { Archetype } from './archetype.js'; 6 | import { 7 | ComponentClass, 8 | ComponentOf, 9 | Constructor, 10 | EntityOf, 11 | Nullable, 12 | Option, 13 | PropertiesOf 14 | } from '../types'; 15 | import { process } from '../constants.js'; 16 | 17 | export class ComponentManager { 18 | public readonly maxComponentTypeCount: number; 19 | 20 | public readonly archetypes: Map>>; 21 | 22 | private readonly _world: WorldType; 23 | private readonly _data: Map; 24 | private readonly _DefaulPool: Nullable>>; 25 | 26 | private readonly _emptyArchetype: Archetype>; 27 | private _lastIdentifier: number; 28 | 29 | public constructor(world: WorldType, options: ComponentManagerOptions) { 30 | const { maxComponentType, ComponentPoolClass = null } = options; 31 | this.maxComponentTypeCount = maxComponentType; 32 | this._world = world; 33 | this.archetypes = new Map(); 34 | this._data = new Map(); 35 | this._DefaulPool = ComponentPoolClass; 36 | this._lastIdentifier = 0; 37 | this._emptyArchetype = new Archetype([], '0'.repeat(maxComponentType)); 38 | this.archetypes.set(this._emptyArchetype.hash, this._emptyArchetype); 39 | } 40 | 41 | public initEntity(entity: EntityOf): void { 42 | this._emptyArchetype.entities.push(entity); 43 | entity._archetype = this._emptyArchetype; 44 | } 45 | 46 | public destroyEntity(entity: EntityOf): void { 47 | const archetype = entity.archetype; 48 | if (archetype) { 49 | this._removeEntityFromArchetype(entity); 50 | } 51 | } 52 | 53 | public addComponentToEntity( 54 | entity: EntityOf, 55 | Class: ComponentClass, 56 | opts?: PropertiesOf 57 | ): void { 58 | if (process.env.NODE_ENV === 'development') { 59 | if (entity.has(Class)) { 60 | const uuid = entity.id; 61 | const name = Class.Name ?? Class.name; 62 | console.warn(`adding duplicate component ${name} to entity ${uuid}`); 63 | } 64 | } 65 | const data = this.registerComponent(Class); 66 | let comp = null; 67 | if (data.pool) { 68 | comp = data.pool.acquire(); 69 | comp._pooled = true; 70 | } else { 71 | comp = new Class(); 72 | } 73 | if ((comp as ComponentData).isDataComponent && opts) { 74 | (comp as ComponentData).init(opts); 75 | } 76 | comp._state = ComponentState.Ready; 77 | // @todo: check in dev mode for duplicate. 78 | entity._components.set(Class, comp); 79 | this.updateArchetype(entity, Class); 80 | } 81 | 82 | public removeComponentFromEntity( 83 | entity: EntityOf, 84 | Class: ComponentClass 85 | ): void { 86 | const component = entity.write(Class)!; 87 | this._removeComponentsImmediate(entity, component); 88 | this.updateArchetype(entity, Class); 89 | } 90 | 91 | public getIdentifier(Class: ComponentClass): number { 92 | return this.registerComponent(Class).identifier; 93 | } 94 | 95 | public registerComponentManual( 96 | Class: ComponentClass, 97 | opts?: ComponentRegisterOptions 98 | ): void { 99 | if (process.env.NODE_ENV === 'development') { 100 | if (this._data.has(Class)) { 101 | const name = Class.Name ?? Class.name; 102 | console.warn(`component ${name} is already registered`); 103 | } 104 | } 105 | if (this._lastIdentifier >= this.maxComponentTypeCount) { 106 | throw new Error('reached maximum number of components registered.'); 107 | } 108 | const identifier = this._lastIdentifier++; 109 | let pool = null as Nullable>; 110 | if (opts && opts.pool) { 111 | pool = opts.pool; 112 | } else if (this._DefaulPool) { 113 | pool = new this._DefaulPool(Class); 114 | pool.expand(1); 115 | } 116 | this._data.set(Class, { identifier, pool }); 117 | } 118 | 119 | public registerComponent(Class: ComponentClass): ComponentCache { 120 | if (!this._data.has(Class)) { 121 | this.registerComponentManual(Class); 122 | } 123 | return this._data.get(Class) as ComponentCache; 124 | } 125 | 126 | public updateArchetype( 127 | entity: EntityOf, 128 | Class: ComponentClass 129 | ): void { 130 | const hash = this._getArchetypeHash(entity, Class, entity.has(Class)); 131 | this._moveEntityToArchetype(entity, hash); 132 | } 133 | 134 | public findEntityByName(name: string): Nullable { 135 | for (const [_, archetype] of this.archetypes) { 136 | const entities = archetype.entities; 137 | for (const entity of entities) { 138 | if (entity.name === name) { 139 | return entity; 140 | } 141 | } 142 | } 143 | return null; 144 | } 145 | 146 | public setComponentPool

>( 147 | Class: ComponentClass>, 148 | pool: Nullable

149 | ): this { 150 | const data = this.registerComponent(Class); 151 | if (data.pool && data.pool.destroy) { 152 | data.pool.destroy(); 153 | } 154 | data.pool = pool; 155 | return this; 156 | } 157 | 158 | public getComponentPool( 159 | Class: ComponentClass 160 | ): Option>> { 161 | return this._data.get(Class)?.pool as Option>>; 162 | } 163 | 164 | private _removeComponentsImmediate( 165 | entity: Entity, 166 | component: Component 167 | ): void { 168 | const Class = component.constructor as ComponentClass; 169 | component._state = ComponentState.None; 170 | if (component.pooled) { 171 | this._data.get(Class)!.pool?.release(component); 172 | } 173 | entity._components.delete(Class); 174 | } 175 | 176 | private _moveEntityToArchetype( 177 | entity: EntityOf, 178 | hash: string 179 | ): void { 180 | this._removeEntityFromArchetype(entity); 181 | if (!this.archetypes.has(hash)) { 182 | const classes = entity.componentClasses; 183 | const archetype = new Archetype>(classes, hash); 184 | this.archetypes.set(archetype.hash, archetype); 185 | this._world._onArchetypeCreated(archetype); 186 | } 187 | const archetype = this.archetypes.get(hash) as Archetype< 188 | EntityOf 189 | >; 190 | archetype.add(entity); 191 | } 192 | 193 | private _removeEntityFromArchetype(entity: EntityOf): void { 194 | const archetype = entity.archetype as Archetype>; 195 | archetype.remove(entity); 196 | // @todo: that may not be really efficient if an archetype is always 197 | // composed of one entity getting attached / dettached. 198 | if (archetype !== this._emptyArchetype && archetype.empty) { 199 | this.archetypes.delete(archetype.hash); 200 | this._world._onArchetypeDestroyed(archetype); 201 | } 202 | } 203 | 204 | private _getArchetypeHash( 205 | entity: Entity, 206 | Class: ComponentClass, 207 | added: boolean 208 | ): string { 209 | const index = this.getIdentifier(Class); 210 | const entry = added ? '1' : '0'; 211 | const arch = entity.archetype!; 212 | return `${arch.hash.substring(0, index)}${entry}${arch.hash.substring( 213 | index + 1 214 | )}`; 215 | } 216 | } 217 | 218 | export interface ComponentRegisterOptions { 219 | pool?: ObjectPool; 220 | } 221 | 222 | export type ComponentManagerOptions = { 223 | maxComponentType: number; 224 | ComponentPoolClass: Nullable>>; 225 | }; 226 | 227 | type ComponentCache = { 228 | identifier: number; 229 | pool: Nullable>; 230 | }; 231 | -------------------------------------------------------------------------------- /src/internals/query-manager.ts: -------------------------------------------------------------------------------- 1 | import { ComponentOperator, Query, QueryComponents } from '../query.js'; 2 | import { World } from '../world.js'; 3 | import { Archetype } from './archetype.js'; 4 | import { ComponentClass, EntityOf } from '../types'; 5 | import { Observer } from '../data/observer.js'; 6 | 7 | export class QueryManager { 8 | private _world: WorldType; 9 | private _queries: Map>; 10 | 11 | public constructor(world: WorldType) { 12 | this._world = world; 13 | this._queries = new Map(); 14 | } 15 | 16 | public request(components: QueryComponents): Query> { 17 | const hash = this._getQueryIdentifier(components); 18 | if (!this._queries.has(hash)) { 19 | // @todo: what happens when a system is unregistered? 20 | // @todo: will not work if a system is created after some 21 | // archetypes already exist. 22 | const query = new Query>(hash, components); 23 | this._queries.set(hash, { query, useCount: 0 }); 24 | this._world._onQueryCreated(query); 25 | } 26 | const cache = this._queries.get(hash)!; 27 | cache.useCount++; 28 | return cache.query; 29 | } 30 | 31 | public release(query: Query>): void { 32 | // @todo: ref count could be moved in Query directly, but that doesn't 33 | // fully makes sense semantically. 34 | const cache = this._queries.get(query.hash); 35 | if (!cache) { 36 | return; 37 | } 38 | if (--cache.useCount === 0) { 39 | // Query isn't used anymore by systems. It can safely be removed. 40 | this._queries.delete(query.hash); 41 | } 42 | } 43 | 44 | public addArchetypeToQuery( 45 | query: Query>, 46 | archetype: Archetype> 47 | ): void { 48 | if (!query.matches(archetype)) { 49 | return; 50 | } 51 | const addedObs = new Observer(query._notifyEntityAdded.bind(query)); 52 | addedObs.id = query.hash; 53 | const removedObs = new Observer(query._notifyEntityRemoved.bind(query)); 54 | removedObs.id = query.hash; 55 | 56 | archetype.onEntityAdded.observe(addedObs); 57 | archetype.onEntityRemoved.observe(removedObs); 58 | query['_archetypes'].push(archetype); 59 | } 60 | 61 | public removeArchetypeFromQuery( 62 | query: Query>, 63 | archetype: Archetype> 64 | ): void { 65 | if (query.matches(archetype)) { 66 | const archetypes = query['_archetypes']; 67 | const index = archetypes.indexOf(archetype); 68 | if (index >= 0) { 69 | archetype.onEntityAdded.unobserveId(query.hash); 70 | archetype.onEntityRemoved.unobserveId(query.hash); 71 | archetypes.splice(index, 1); 72 | } 73 | } 74 | } 75 | 76 | public addArchetype(archetype: Archetype>): void { 77 | const queries = this._queries; 78 | // @todo: how to optimize that when a lot of archetypes are getting created? 79 | for (const [_, cache] of queries) { 80 | // @todo: ref count could be moved in Query directly, but that doesn't 81 | // fully makes sense semantically. 82 | this.addArchetypeToQuery(cache.query, archetype); 83 | } 84 | } 85 | 86 | public removeArchetype(archetype: Archetype>): void { 87 | const queries = this._queries; 88 | // @todo: how to optimize that when a lot of archetypes are getting destroyed? 89 | for (const [_, cache] of queries) { 90 | // @todo: ref count could be moved in Query directly, but that doesn't 91 | // fully makes sense semantically. 92 | this.removeArchetypeFromQuery(cache.query, archetype); 93 | } 94 | } 95 | 96 | private _getQueryIdentifier(components: QueryComponents): string { 97 | const count = components.length; 98 | const idList = new Array(count); 99 | for (let i = 0; i < count; ++i) { 100 | // @todo: move somewhere else 101 | const comp = components[i]; 102 | const Class = (comp as ComponentOperator).isOperator 103 | ? (comp as ComponentOperator).Class 104 | : (comp as ComponentClass); 105 | const compId = this._world.getComponentId(Class); 106 | if ((comp as ComponentOperator).isOperator) { 107 | idList[i] = `${(comp as ComponentOperator).kind}(${compId})`; 108 | } else { 109 | idList[i] = compId + ''; 110 | } 111 | } 112 | return idList.sort().join('_'); 113 | } 114 | } 115 | 116 | type QueryCache = { 117 | query: Query>; 118 | useCount: number; 119 | }; 120 | -------------------------------------------------------------------------------- /src/internals/system-manager.ts: -------------------------------------------------------------------------------- 1 | import { sortByOrder, System } from '../system.js'; 2 | import { SystemGroup } from '../system-group.js'; 3 | import { SystemRegisterOptions, World } from '../world.js'; 4 | import { Constructor, Option, SystemClass, SystemGroupClass } from '../types'; 5 | import { process } from '../constants.js'; 6 | 7 | /** 8 | * Manages registered systems in a world instance 9 | * 10 | * @category System 11 | * 12 | * @hidden 13 | */ 14 | export class SystemManager { 15 | private _world: WorldType; 16 | private _groups: SystemGroup[]; 17 | private _systems: Map; 18 | 19 | public constructor(world: WorldType) { 20 | this._world = world; 21 | this._groups = [new SystemGroup(this._world)]; 22 | this._systems = new Map(); 23 | } 24 | 25 | public register>( 26 | Class: SystemClass, 27 | opts: SystemRegisterOptions = {} 28 | ): this { 29 | if (this._systems.has(Class)) { 30 | if (process.env.NODE_ENV === 'development') { 31 | const name = Class.Name ?? Class.name; 32 | throw new Error(`system '${name}' is already registered`); 33 | } 34 | return this; 35 | } 36 | 37 | const { 38 | group = (Class.Group ?? SystemGroup) as SystemGroupClass< 39 | SystemGroup 40 | > 41 | } = opts; 42 | let groupInstance = this._groups.find((g: SystemGroup) => { 43 | return (g.constructor as Constructor>) === group; 44 | }); 45 | if (!groupInstance) { 46 | groupInstance = new group(this._world); 47 | this._groups.push(groupInstance); 48 | } 49 | const system = new Class(groupInstance, opts); 50 | groupInstance.add(system); 51 | groupInstance.sort(); 52 | this._systems.set(Class, system); 53 | if (system.init) system.init(); 54 | return this; 55 | } 56 | 57 | public unregister>(Class: SystemClass): void { 58 | const system = this._systems.get(Class) as T; 59 | if (!system) { 60 | if (process.env.NODE_ENV === 'development') { 61 | const name = Class.Name ?? Class.name; 62 | throw new Error(`system '${name}' is wasn't registered`); 63 | } 64 | return; 65 | } 66 | // Destroys queries. 67 | for (const name in system.queries) { 68 | this._world._releaseQuery(system.queries[name]); 69 | } 70 | // Removes system from its group. 71 | const group = system.group; 72 | group._remove(system); 73 | if (group.isEmpty) { 74 | // The group is in the `_groups` array, otherwise there is an issue 75 | // somewhere else. 76 | this._groups.splice(this._groups.indexOf(group), 1); 77 | } 78 | if (system.dispose) system.dispose(); 79 | // Deletes system entry. 80 | this._systems.delete(Class); 81 | } 82 | 83 | /** 84 | * Executes every registered group, and so systems 85 | * 86 | * @param delta - Delta time with previous call to execute 87 | */ 88 | public execute(delta: number): void { 89 | const groups = this._groups; 90 | for (const group of groups) { 91 | if (group.enabled) { 92 | group.execute(delta); 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * Sorts group using priorities number 99 | * 100 | * **Note**: this only sorts group relative to each other, and doesn't 101 | * sort systems in group. 102 | */ 103 | public sort(): void { 104 | this._groups.sort(sortByOrder); 105 | } 106 | 107 | /** 108 | * Returns the group of type `Class` 109 | * 110 | * @param Class SystemGroup class used to find instance 111 | * 112 | * @return Returns the instance of type `Class` if it exists. `undefined` 113 | * otherwise 114 | */ 115 | public group(Class: Constructor): Option { 116 | return this._groups.find((group: SystemGroup) => { 117 | return Class === group.constructor; 118 | }) as Option; 119 | } 120 | 121 | /** 122 | * Returns the system of type `Class` 123 | * 124 | * @param Class System class used to find instance 125 | * 126 | * @return Returns the instance of type `Class` if it exists. `undefined` 127 | * otherwise 128 | */ 129 | public system(Class: SystemClass): Option { 130 | return this._systems.get(Class) as Option; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/pool.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from './types'; 2 | 3 | export class DefaultPool { 4 | protected readonly _class; 5 | protected readonly _list: (T | null)[]; 6 | protected readonly _growPercentage: number; 7 | protected _freeSlot: number; 8 | 9 | public constructor( 10 | Class: Constructor, 11 | options: Partial> = {} 12 | ) { 13 | this._class = Class; 14 | this._list = []; 15 | this._growPercentage = options.growthPercentage ?? 0.2; 16 | this._freeSlot = 0; 17 | if (options.initialCount) { 18 | this.expand(options.initialCount); 19 | } 20 | } 21 | 22 | public acquire(): T { 23 | if (this._freeSlot === this._list.length) { 24 | this.expand(Math.round(this._list.length * 0.2) + 1); 25 | } 26 | if (this._freeSlot === -1) { 27 | this._freeSlot = 0; 28 | } 29 | const val = this._list[this._freeSlot]!; 30 | this._list[this._freeSlot++] = null; 31 | return val; 32 | } 33 | 34 | public release(value: T): void { 35 | if (this._freeSlot >= 0) { 36 | this._list[--this._freeSlot] = value; 37 | } 38 | } 39 | 40 | public expand(count: number): void { 41 | if (count <= 0) { 42 | return; 43 | } 44 | const Class = this._class; 45 | const start = this._list.length; 46 | const end = start + count; 47 | this._list.length = end; 48 | for (let i = start; i < end; ++i) { 49 | this._list[i] = new Class(); 50 | } 51 | } 52 | 53 | public get allocatedSize(): number { 54 | return this._list.length; 55 | } 56 | 57 | public get used(): number { 58 | return this._freeSlot >= 0 ? this._freeSlot : 0; 59 | } 60 | } 61 | 62 | export interface ObjectPool { 63 | destroy?: () => void; 64 | acquire(): T; 65 | release(value: T): void; 66 | expand(count: number): void; 67 | } 68 | 69 | interface DefaultPoolOptions { 70 | initialCount: number; 71 | growthPercentage: number; 72 | } 73 | -------------------------------------------------------------------------------- /src/property.ts: -------------------------------------------------------------------------------- 1 | import { Constructor, Nullable } from './types'; 2 | 3 | /** 4 | * Generic component property. 5 | * 6 | * Properties are on [[ComponentData]] objects. Properties should be defined 7 | * statically, and allow component to be copied and initialized easily 8 | * 9 | * @class property 10 | */ 11 | export class Property { 12 | /** 13 | * Default value of the property. The default value is used when the 14 | * component is initialized if no value for this property is provided 15 | */ 16 | public default: T; 17 | 18 | public constructor(typeDefault: T, defaultVal?: T) { 19 | this.default = defaultVal ?? typeDefault; 20 | } 21 | 22 | /** 23 | * Copies the value of `src` into `dest` and returns `dest` 24 | * 25 | * **Note**: copy allow to copy primitive property types 26 | * (number, string, boolean), as well as custom types. For more 27 | * information about custom types, please look at [[ArrayProperty]] and 28 | * [[CopyableProperty]] 29 | * 30 | * @param dest The destination reference 31 | * @param src The source reference 32 | * @return The destination reference 33 | */ 34 | public copy(dest: T, src: T): T { 35 | return src; 36 | } 37 | 38 | /** 39 | * Copies the default value of this property into `dest` 40 | * 41 | * @param dest The destination reference 42 | * @return The destination reference 43 | */ 44 | public copyDefault(dest: T): T { 45 | return this.copy(dest, this.default); 46 | } 47 | 48 | /** 49 | * Returns a clone of the default value saved in this property 50 | * 51 | * @return A cloned default value 52 | */ 53 | public cloneDefault(): T { 54 | return this.default; 55 | } 56 | } 57 | 58 | /** 59 | * Array property 60 | * 61 | * @class property 62 | */ 63 | export class ArrayProperty extends Property { 64 | public constructor(opts?: T[]) { 65 | super([], opts); 66 | } 67 | 68 | /** @inheritdoc */ 69 | public copy(dst: T[], src: T[]): T[] { 70 | dst.length = 0; 71 | dst.push(...src); 72 | return dst; 73 | } 74 | 75 | /** @inheritdoc */ 76 | public cloneDefault(): T[] { 77 | return this.copyDefault([]); 78 | } 79 | } 80 | 81 | /** 82 | * Copyable property. 83 | * 84 | * Copyable types are object implementing the following methods: 85 | * 86 | * ```js 87 | * class MyObject { 88 | * 89 | * copy(source) { 90 | * // Copy data from source into `this`. 91 | * return this; 92 | * } 93 | * clone() { 94 | * return new (this.constructor)().copy(this); 95 | * } 96 | * } 97 | * ``` 98 | * 99 | * It's possible to use your own copyable types as component properties using 100 | * this class 101 | * 102 | * @class property 103 | */ 104 | export class CopyableProperty extends Property { 105 | public constructor(options: CopyClonableOptions) { 106 | super(new options.type(), options.default); 107 | // @todo: check that type is really copy/clonable in dev mode. 108 | } 109 | 110 | /** @inheritdoc */ 111 | public copy(dst: T, src: T): T { 112 | return dst.copy(src); 113 | } 114 | 115 | /** @inheritdoc */ 116 | public copyDefault(dest: T): T { 117 | return dest.copy(this.default); 118 | } 119 | 120 | /** @inheritdoc */ 121 | public cloneDefault(): T { 122 | return this.default.clone(); 123 | } 124 | } 125 | 126 | /** 127 | * Creates a new property of type reference. 128 | * 129 | * **Note**: this function is simply a shortcut for: 130 | * ```js 131 | * new new Property(null, ...); 132 | * ``` 133 | * 134 | * ## Examples 135 | * 136 | * ```js 137 | * class MyComponent extends ComponentData {} 138 | * MyComponent.Properties = { 139 | * myRef: RefProp(null) 140 | * }; 141 | * ``` 142 | * 143 | * ### Decorator 144 | * 145 | * ```ts 146 | * class MyComponent extends ComponentData { 147 | * ref(null) 148 | * myRef!: any | null; 149 | * } 150 | * ``` 151 | * @return A new [[Property]] 152 | */ 153 | export function RefProp(defaultVal?: Nullable) { 154 | return new Property(null, defaultVal); 155 | } 156 | 157 | /** 158 | * Creates a new property of type boolean. 159 | * 160 | * **Note**: this function is simply a shortcut for: 161 | * ```js 162 | * new new Property(false, ...); 163 | * ``` 164 | * 165 | * ## Examples 166 | * 167 | * ```js 168 | * class MyComponent extends ComponentData {} 169 | * MyComponent.Properties = { 170 | * myBoolean: BooleanProp(true) 171 | * }; 172 | * ``` 173 | * 174 | * ### Decorator 175 | * 176 | * ```ts 177 | * class MyComponent extends ComponentData { 178 | * boolean(true) 179 | * myBoolean!: boolean; 180 | * } 181 | * ``` 182 | * @return A new [[Property]] 183 | */ 184 | export function BooleanProp(defaultVal?: boolean) { 185 | return new Property(false, defaultVal); 186 | } 187 | 188 | /** 189 | * Creates a new property of type number. 190 | * 191 | * **Note**: this function is simply a shortcut for: 192 | * ```js 193 | * new new Property(0, ...); 194 | * ``` 195 | * 196 | * ## Examples 197 | * 198 | * ```js 199 | * class MyComponent extends ComponentData {} 200 | * MyComponent.Properties = { 201 | * myNumber: NumberProp(100) 202 | * }; 203 | * ``` 204 | * 205 | * ### Decorator 206 | * 207 | * ```ts 208 | * class MyComponent extends ComponentData { 209 | * number(100) 210 | * myNumber!: number; 211 | * } 212 | * ``` 213 | * @return A new [[Property]] 214 | */ 215 | export function NumberProp(defaultVal?: number) { 216 | return new Property(0, defaultVal); 217 | } 218 | 219 | /** 220 | * Creates a new property of type string. 221 | * 222 | * **Note**: this function is simply a shortcut for: 223 | * ```js 224 | * new new Property('', ...); 225 | * ``` 226 | * 227 | * ## Examples 228 | * 229 | * ```js 230 | * class MyComponent extends ComponentData {} 231 | * MyComponent.Properties = { 232 | * myString: StringProp('Hello World!') 233 | * }; 234 | * ``` 235 | * 236 | * ### Decorator 237 | * 238 | * ```ts 239 | * class MyComponent extends ComponentData { 240 | * string('Hello World!') 241 | * myString!: string; 242 | * } 243 | * ``` 244 | * @return A new [[Property]] 245 | */ 246 | export function StringProp(defaultVal?: string) { 247 | return new Property('', defaultVal); 248 | } 249 | 250 | /** 251 | * Creates a new property of type [[ArrayProperty]]. 252 | * 253 | * **Note**: this function is simply a shortcut for: 254 | * ```js 255 | * new ArrayProperty(); 256 | * ``` 257 | * 258 | * ## Examples 259 | * 260 | * ```js 261 | * class MyComponent extends ComponentData {} 262 | * MyComponent.Properties = { 263 | * myArray: ArrayProp([ 0, 1, 2, 3 ]) 264 | * }; 265 | * ``` 266 | * 267 | * ### Decorator 268 | * 269 | * ```ts 270 | * class MyComponent extends ComponentData { 271 | * array([ 0, 1, 2, 3 ]) 272 | * myArray!: number[]; 273 | * } 274 | * ``` 275 | * @return A new [[ArrayProperty]] 276 | */ 277 | export function ArrayProp(defaultVal?: T[]) { 278 | return new ArrayProperty(defaultVal); 279 | } 280 | 281 | /** 282 | * Creates a new property of type [[CopyableProperty]]. 283 | * 284 | * **Note**: this function is simply a shortcut for: 285 | * ```js 286 | * new CopyableProperty(...); 287 | * ``` 288 | * 289 | * ## Examples 290 | * 291 | * ```js 292 | * class Vector2() { 293 | * constructor(x, y) { 294 | * this.x = x; 295 | * this.y = y; 296 | * } 297 | * copy(source) { 298 | * this.x = source.x; 299 | * this.y = source.y; 300 | * return this; 301 | * } 302 | * clone(): Vector2 { 303 | * return new (this.constructor)().copy(this); 304 | * } 305 | * } 306 | * 307 | * class MyComponent extends ComponentData {} 308 | * MyComponent.Properties = { 309 | * myCopyable: CopyableProp({ type: Vector2, default: new Vector2(0, 0) }) 310 | * }; 311 | * ``` 312 | * 313 | * ### Decorator 314 | * 315 | * ```ts 316 | * class MyComponent extends ComponentData { 317 | * copyable({ type: Vector2, default: new Vector2(0, 0) }) 318 | * myCopyable!: Vector2; 319 | * } 320 | * ``` 321 | * @return A new [[CopyableProperty]] 322 | */ 323 | export function CopyableProp( 324 | opts: CopyClonableOptions 325 | ) { 326 | return new CopyableProperty(opts); 327 | } 328 | 329 | export interface PropertyOptions { 330 | default?: T; 331 | } 332 | 333 | export interface CopyClonableOptions { 334 | type: Constructor; 335 | default?: T; 336 | } 337 | 338 | export interface CopyClonableType { 339 | copy(source: this): this; 340 | clone(): this; 341 | } 342 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from './entity.js'; 2 | import { Archetype } from './internals/archetype.js'; 3 | import { ComponentClass, Option } from './types'; 4 | 5 | enum QueryComponentOperatorKind { 6 | Not = 'not' 7 | } 8 | 9 | export function Not(Class: ComponentClass) { 10 | return { Class, kind: QueryComponentOperatorKind.Not, isOperator: true }; 11 | } 12 | 13 | /** 14 | * Queries allow to retrieve entities based on the component they have. 15 | * 16 | * Flecs query language can perform the following operations: 17 | * * Intersection of component (&) 18 | * * Negation (!) 19 | * 20 | * **Notes**: using this class as a standalone will not work because the 21 | * world populates tempory data into the query. 22 | * 23 | * @category query 24 | */ 25 | export class Query { 26 | public onEntityAdded?: (entity: E) => void; 27 | public onEntityRemoved?: (entity: E) => void; 28 | 29 | /** @hidden */ 30 | private readonly _hash: string; 31 | 32 | /** @hidden */ 33 | private _archetypes: Archetype[]; 34 | 35 | /** @hidden */ 36 | private _classes: ComponentClass[]; 37 | 38 | /** @hidden */ 39 | private _notClasses: ComponentClass[]; 40 | 41 | public constructor(hash: string, components: QueryComponents) { 42 | this._hash = hash; 43 | this._archetypes = []; 44 | this._classes = []; 45 | this._notClasses = []; 46 | for (const comp of components) { 47 | if ((comp as ComponentOperator).isOperator) { 48 | this._notClasses.push((comp as ComponentOperator).Class); 49 | } else { 50 | this._classes.push(comp as ComponentClass); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Executes the callback on all entities matching this query 57 | * 58 | * @param cb - Callback executing on every entity 59 | */ 60 | public execute(cb: QueryExecutorVoid): void { 61 | const archetypes = this._archetypes; 62 | for (let archId = archetypes.length - 1; archId >= 0; --archId) { 63 | const entities = archetypes[archId].entities; 64 | for (let entityId = entities.length - 1; entityId >= 0; --entityId) { 65 | cb(entities[entityId]); 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * Executes the callback on all entities matching this query. If the callback 72 | * returns `true` at any point, iteration will stop 73 | * 74 | * @param cb - Callback executing on every entity 75 | */ 76 | public executeUntil(cb: QueryExecutor): void { 77 | const archetypes = this._archetypes; 78 | for (let archId = archetypes.length - 1; archId >= 0; --archId) { 79 | const entities = archetypes[archId].entities; 80 | for (let entityId = entities.length - 1; entityId >= 0; --entityId) { 81 | if (cb(entities[entityId])) { 82 | return; 83 | } 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Returns true if this query definition matches the given archetype 90 | * 91 | * @param archetype - Archetype to test 92 | * @return `true` if a match occurs, `false` otherwise 93 | */ 94 | public matches(archetype: Archetype): boolean { 95 | const notClasses = this._notClasses; 96 | for (const not of notClasses) { 97 | if (archetype.components.has(not)) { 98 | return false; 99 | } 100 | } 101 | const classes = this._classes; 102 | for (const comp of classes) { 103 | if (!archetype.components.has(comp)) { 104 | return false; 105 | } 106 | } 107 | return true; 108 | } 109 | 110 | /** 111 | * Returns `true` if this query has the entity `entity` 112 | * 113 | * @param entity - Entity to check 114 | * @return `true` if the entity matches this query, false otherwise 115 | */ 116 | public hasEntity(entity: E): boolean { 117 | for (const archetype of this._archetypes) { 118 | if (entity.archetype === archetype) { 119 | return true; 120 | } 121 | } 122 | return false; 123 | } 124 | 125 | public get first(): Option { 126 | const archetypes = this._archetypes; 127 | if (archetypes.length > 0 && archetypes[0].entities.length > 0) { 128 | return archetypes[0].entities[0]; 129 | } 130 | return undefined; 131 | } 132 | 133 | /** Returns the list archetypes stored in this query */ 134 | public get archetypes(): Archetype[] { 135 | return this._archetypes; 136 | } 137 | 138 | public get hash(): string { 139 | return this._hash; 140 | } 141 | 142 | public _notifyEntityAdded(entity: E): void { 143 | if (this.onEntityAdded) { 144 | this.onEntityAdded(entity); 145 | } 146 | } 147 | 148 | public _notifyEntityRemoved(entity: E): void { 149 | if (this.onEntityRemoved) { 150 | this.onEntityRemoved(entity); 151 | } 152 | } 153 | } 154 | 155 | export type ComponentOperator = { 156 | Class: ComponentClass; 157 | kind: QueryComponentOperatorKind; 158 | isOperator: boolean; 159 | }; 160 | export type QueryComponents = (ComponentClass | ComponentOperator)[]; 161 | export type QueryExecutorVoid = (entity: Entity) => void; 162 | export type QueryExecutor = (entity: Entity) => boolean; 163 | -------------------------------------------------------------------------------- /src/system-group.ts: -------------------------------------------------------------------------------- 1 | import { World } from './world.js'; 2 | import { sortByOrder, System } from './system.js'; 3 | import { SystemClass } from './types'; 4 | 5 | /** 6 | * A SystemGroup is used to group systems together. Systems belonging to the 7 | * same group can be sorted relative to each other. 8 | * 9 | * Groups allow to execute logic at different stage. For instance, it's common 10 | * to first update all the transforms, and then to render all objects. Groups 11 | * can help for those use cases as they will clearly help to separate the 12 | * execution into main steps: 13 | * * Update 14 | * * System1 15 | * * ... 16 | * * SystemN 17 | * * Render 18 | * * System1 19 | * * ... 20 | * * SystemN 21 | * 22 | * Using groups, it's also easier for developers to share systems to other 23 | * developers and keep a consistent default ordering 24 | * 25 | * @category system 26 | */ 27 | export class SystemGroup { 28 | /** Name of the system group class */ 29 | public static Name?: string; 30 | 31 | /** 32 | * If `true`, the system group will be executed 33 | * 34 | * When a group is disabled, none of its systems will be executed 35 | */ 36 | public enabled: boolean; 37 | 38 | /** 39 | * Order of the group for priority-based sorting. Higher number means that 40 | * the group will run last 41 | */ 42 | public order: number; 43 | 44 | /** If `true`, systems will be sorted first using topological sorting */ 45 | public useTopologicalSorting: boolean; 46 | 47 | /** 48 | * @hidden 49 | */ 50 | protected readonly _world: WorldType; 51 | 52 | /** 53 | * @hidden 54 | */ 55 | private _systems: System[]; 56 | 57 | public constructor(world: WorldType) { 58 | this.enabled = true; 59 | this.order = 0; 60 | this.useTopologicalSorting = true; 61 | this._world = world; 62 | this._systems = []; 63 | } 64 | 65 | /** 66 | * Adds the given system to this group. 67 | * 68 | * **Note**: users **should'nt** call this method manually 69 | * 70 | * @hidden 71 | */ 72 | public add(system: System): void { 73 | // @todo: checks it's not already added. 74 | this._systems.push(system); 75 | } 76 | 77 | /** 78 | * Executes the group, i.e., executes all its systems sequentially 79 | */ 80 | public execute(delta: number): void { 81 | const systems = this._systems; 82 | for (const system of systems) { 83 | if (system.enabled) { 84 | system.execute(delta); 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * Sorts the systems topologically first, and then based on the `order` they 91 | * define. 92 | * 93 | * **Note**: if the group property `useTopologicalSorting` is set to `false`, 94 | * no topological sorting will occur 95 | */ 96 | public sort(): void { 97 | if (this.useTopologicalSorting) { 98 | this._sortTopological(); 99 | } 100 | this._systems.sort(sortByOrder); 101 | } 102 | 103 | /** 104 | * @hidden 105 | */ 106 | private _sortTopological(): void { 107 | const nodes = new Map(); 108 | const systems = this._systems; 109 | 110 | for (const system of systems) { 111 | // @todo: check for duplicate. 112 | const Class = system.constructor as SystemClass; 113 | nodes.set(Class, { next: [], system, visited: false }); 114 | } 115 | for (const [Class, node] of nodes) { 116 | if (Class.UpdateAfter) { 117 | for (const AfterClass of Class.UpdateAfter) { 118 | nodes.get(AfterClass)?.next.push(Class); 119 | } 120 | } 121 | if (Class.UpdateBefore) { 122 | for (const BeforeClass of Class.UpdateBefore) { 123 | if (nodes.has(BeforeClass)!) { 124 | node.next.push(BeforeClass); 125 | } 126 | } 127 | } 128 | } 129 | // @todo: use indices instead of changing lenght. 130 | systems.length = 0; 131 | nodes.forEach((node) => { 132 | const Class = node.system.constructor as SystemClass; 133 | topologicalSortRec(systems, Class, nodes); 134 | }); 135 | } 136 | 137 | /** Returns the reference to the [[World]] holding this group */ 138 | public get world(): WorldType { 139 | return this._world; 140 | } 141 | 142 | public get isEmpty(): boolean { 143 | return this._systems.length === 0; 144 | } 145 | 146 | /** 147 | * @param {System} system 148 | * 149 | * @hidden 150 | */ 151 | public _remove(system: System): void { 152 | this._systems.splice(this._systems.indexOf(system), 1); 153 | } 154 | } 155 | 156 | /** 157 | * @hidden 158 | */ 159 | function topologicalSortRec( 160 | result: System[], 161 | Class: SystemClass, 162 | visited: Map 163 | ): void { 164 | const node = visited.get(Class)!; 165 | if (!node || node.visited) { 166 | return; 167 | } 168 | node.visited = true; 169 | for (const next of node.next) { 170 | topologicalSortRec(result, next, visited); 171 | } 172 | result.unshift(node.system); 173 | } 174 | 175 | /** @hidden */ 176 | type Node = { 177 | next: SystemClass[]; 178 | visited: boolean; 179 | system: System; 180 | }; 181 | -------------------------------------------------------------------------------- /src/system.ts: -------------------------------------------------------------------------------- 1 | import { Query, QueryComponents } from './query.js'; 2 | import { SystemGroup } from './system-group.js'; 3 | import { World } from './world.js'; 4 | import { Constructor, EntityOf, SystemClass } from './types'; 5 | 6 | /** 7 | * Systems query entities based on their components and apply logic to those 8 | * entities. 9 | * 10 | * For instance, a "classic" game would have systems such as: 11 | * * PhysicsSystem: looks for entities with component like `RigidBody`, 12 | * `Transform` 13 | * * RendererSystem: running as the last system of the world, rendering all 14 | * meshes based on their transform 15 | * * etc... 16 | * 17 | * @category system 18 | */ 19 | export abstract class System { 20 | /** Name of the system class */ 21 | public static Name?: string; 22 | /** 23 | * List of static queries. 24 | * 25 | * Static queries are created when the system is instanciated. For 26 | * performance reasons, it's better if you use mostly static queries. 27 | * 28 | * ## Examples 29 | * 30 | * ```js 31 | * class MySystem extends System {} 32 | * MySystem.Queries = { 33 | * // Query all entities with `MeshComponent` and `TransformComponent`. 34 | * mesh: [ MeshComponent, TransformComponent ] 35 | * } 36 | * 37 | * 38 | * ``` 39 | */ 40 | public static Queries?: StaticQueries; 41 | 42 | /** 43 | * [[SystemGroup]] class in which this system should be added. 44 | * 45 | * **Note**: if no `Group` is specified, the system is added to a default 46 | * group 47 | */ 48 | public static Group?: Constructor; 49 | 50 | /** 51 | * List of systems classes that should be executed **before** this system 52 | * 53 | * **Note**: this will only affect systems that are in the same group as this 54 | * one 55 | */ 56 | public static UpdateAfter?: SystemClass[]; 57 | 58 | /** 59 | * List of systems classes that should be executed **after** this system 60 | * 61 | * **Note**: this will only affect systems that are in the same group as this 62 | * one 63 | */ 64 | public static UpdateBefore?: SystemClass[]; 65 | 66 | /** If `true`, the system will be executed */ 67 | public enabled: boolean; 68 | 69 | /** 70 | * Order of the system for priority-based sorting. Higher number means that 71 | * the system will run last 72 | */ 73 | public order: number; 74 | 75 | /** 76 | * Queries map built automatically from the `Queries` static list on 77 | * instantiation of the system 78 | */ 79 | public readonly queries: { 80 | [key: string]: Query>; 81 | }; 82 | 83 | /** 84 | * Reference to the group holding this system 85 | * 86 | * @hidden 87 | */ 88 | private readonly _group: SystemGroup; 89 | 90 | public constructor( 91 | group: SystemGroup, 92 | options: Partial 93 | ) { 94 | this.enabled = true; 95 | this.order = options.order ?? 0; 96 | this.queries = {}; 97 | this._group = group; 98 | 99 | // @todo: When system is unregistered, how do we clean those queries in 100 | // the QueryManager? 101 | this.buildStaticQueries(); 102 | } 103 | 104 | /** 105 | * Builds the static queries from `Queries` into the `queries` attribute of 106 | * this instance. 107 | * 108 | * **Note**: this is a slow operation and should be done **only** if you 109 | * modify the static list of queries. 110 | * 111 | * **Note**: ideally, you should **never** have to modify a static list of 112 | * queries, **especially** after the system is instanciated 113 | * 114 | * @return This instance 115 | */ 116 | public buildStaticQueries(): this { 117 | const world = this._group.world; 118 | for (const name in this.queries) { 119 | // Destroys the already-existing queries. 120 | world._releaseQuery(this.queries[name]); 121 | delete this.queries[name]; 122 | } 123 | 124 | // Build static queries from the inhertiance hierarchy. 125 | let Class = this.constructor as SystemClass; 126 | do { 127 | const staticQueries = Class.Queries; 128 | if (staticQueries) { 129 | for (const name in staticQueries) { 130 | if (!this.queries.hasOwnProperty(name)) { 131 | this.queries[name] = world._requestQuery(staticQueries[name]); 132 | } 133 | } 134 | } 135 | } while (!!(Class = Object.getPrototypeOf(Class)) && Class !== System); 136 | 137 | return this; 138 | } 139 | 140 | /** 141 | * Executes the system, i.e., applies the system logic to the world 142 | */ 143 | public abstract execute(delta: number): void; 144 | 145 | /** 146 | * Called when the System is registered in a World 147 | */ 148 | public init?(): void; 149 | 150 | /** 151 | * Called when the System is removed from a World 152 | */ 153 | public dispose?(): void; 154 | 155 | /** Returns the group in which this system belongs */ 156 | public get group(): SystemGroup { 157 | return this._group; 158 | } 159 | } 160 | 161 | /** 162 | * Default priority-based sorting for systems. 163 | * 164 | * @hidden 165 | */ 166 | export function sortByOrder(a: Orderable, b: Orderable): number { 167 | return a.order - b.order; 168 | } 169 | 170 | /** 171 | * Options object to create a system 172 | */ 173 | export interface SystemOptions { 174 | /** 175 | * Order of the system for priority-based sorting. Higher number means that 176 | * the system will run last. 177 | * 178 | * Defaults to `0` 179 | */ 180 | order: number; 181 | } 182 | 183 | export interface StaticQueries { 184 | [key: string]: QueryComponents; 185 | } 186 | 187 | export type Orderable = { order: number }; 188 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Component, ComponentData, Properties } from './component'; 2 | import { Entity } from './entity'; 3 | import { ObjectPool } from './pool'; 4 | import { Property } from './property'; 5 | import { StaticQueries, System } from './system'; 6 | import { SystemGroup } from './system-group'; 7 | import { World } from './world'; 8 | 9 | /** Describes a type T that can be null */ 10 | export type Nullable = T | null; 11 | /** Describes a type T that can be undefined */ 12 | export type Option = T | undefined; 13 | 14 | export type ComponentOf

= P extends ObjectPool ? C : never; 15 | /** Inner Entity type derived from a World type */ 16 | export type EntityOf = W extends World ? E : never; 17 | /** Inner list of propereties type derived from a Component type */ 18 | export type PropertiesOf = Partial< 19 | Omit 20 | >; 21 | 22 | export type Constructor = new (...args: any[]) => T; 23 | 24 | export type EntityClass = new (name?: string) => T; 25 | 26 | /** Class type for a SystemGroup derived type */ 27 | export type SystemGroupClass< 28 | T extends SystemGroup = SystemGroup 29 | > = Constructor & { 30 | readonly Mame?: string; 31 | }; 32 | 33 | /** Class type for a System derived type */ 34 | export type SystemClass = (new ( 35 | group: SystemGroup, 36 | opts: any 37 | ) => T) & { 38 | Name?: string; 39 | Queries?: StaticQueries; 40 | Group?: Constructor; 41 | UpdateAfter?: SystemClass[]; 42 | UpdateBefore?: SystemClass[]; 43 | }; 44 | 45 | /** Class type for a Component derived type */ 46 | export type ComponentClass = Constructor & { 47 | Name?: string; 48 | }; 49 | 50 | /** Class type for a ComponentData derived type */ 51 | export type DataComponentClass< 52 | T extends ComponentData = ComponentData 53 | > = Constructor & { 54 | Name?: string; 55 | Properties?: Properties; 56 | readonly _MergedProoperties: Properties; 57 | }; 58 | 59 | /** Class type for a Property derived type */ 60 | export type PropertyClass< 61 | T extends Property = Property 62 | > = Constructor; 63 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a unique identifier, i.e., UUID 3 | * 4 | * ## Notes 5 | * 6 | * This function has been taken directly from Three.js: 7 | * https://github.com/mrdoob/three.js/blob/dev/src/math/MathUtils.js 8 | * 9 | * @return A unique identifier stored in a string 10 | */ 11 | export const createUUID = (function () { 12 | const lut: string[] = []; 13 | for (let i = 0; i < 256; i++) { 14 | lut[i] = (i < 16 ? '0' : '') + i.toString(16); 15 | } 16 | return function () { 17 | // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/21963136#21963136 18 | const d0 = (Math.random() * 0xffffffff) | 0; 19 | const d1 = (Math.random() * 0xffffffff) | 0; 20 | const d2 = (Math.random() * 0xffffffff) | 0; 21 | const d3 = (Math.random() * 0xffffffff) | 0; 22 | const uuid = 23 | lut[d0 & 0xff] + 24 | lut[(d0 >> 8) & 0xff] + 25 | lut[(d0 >> 16) & 0xff] + 26 | lut[(d0 >> 24) & 0xff] + 27 | '-' + 28 | lut[d1 & 0xff] + 29 | lut[(d1 >> 8) & 0xff] + 30 | '-' + 31 | lut[((d1 >> 16) & 0x0f) | 0x40] + 32 | lut[(d1 >> 24) & 0xff] + 33 | '-' + 34 | lut[(d2 & 0x3f) | 0x80] + 35 | lut[(d2 >> 8) & 0xff] + 36 | '-' + 37 | lut[(d2 >> 16) & 0xff] + 38 | lut[(d2 >> 24) & 0xff] + 39 | lut[d3 & 0xff] + 40 | lut[(d3 >> 8) & 0xff] + 41 | lut[(d3 >> 16) & 0xff] + 42 | lut[(d3 >> 24) & 0xff]; 43 | // .toUpperCase() here flattens concatenated strings to save heap memory space. 44 | return uuid.toUpperCase(); 45 | }; 46 | })(); 47 | -------------------------------------------------------------------------------- /src/world.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from './entity.js'; 2 | import { 3 | ComponentManager, 4 | ComponentRegisterOptions 5 | } from './internals/component-manager.js'; 6 | import { QueryManager } from './internals/query-manager.js'; 7 | import { SystemManager } from './internals/system-manager.js'; 8 | import { System } from './system.js'; 9 | import { SystemGroup } from './system-group.js'; 10 | import { Component } from './component.js'; 11 | import { Query, QueryComponents } from './query.js'; 12 | import { DefaultPool, ObjectPool } from './pool.js'; 13 | import { 14 | ComponentClass, 15 | ComponentOf, 16 | Constructor, 17 | EntityOf, 18 | EntityClass, 19 | Nullable, 20 | Option, 21 | PropertiesOf, 22 | SystemClass, 23 | SystemGroupClass 24 | } from './types'; 25 | import { Archetype } from './internals/archetype.js'; 26 | 27 | /** 28 | * The world is the link between entities and systems. The world is composed 29 | * of system instances that execute logic on entities with selected components. 30 | * 31 | * An application can have as many worlds as needed. However, always remember 32 | * that entities and components instances are bound to the world that create 33 | * them. 34 | * 35 | * ## Creation 36 | * 37 | * It's possible to create a world using: 38 | * 39 | * ```js 40 | * const world = new World(); 41 | * ``` 42 | * 43 | * You can also change the default behaviour of the world using: 44 | * 45 | * ```js 46 | * const world = new World({ 47 | * systems: ..., 48 | * components: ..., 49 | * maxComponentType: ..., 50 | * useManualPooling: ..., 51 | * EntityClass: ... 52 | * }); 53 | * ``` 54 | * 55 | * For more information about the options, please have a look a the 56 | * [[WorldOptions]] interface 57 | * 58 | * ## Creating Entities 59 | * 60 | * Entities should **only** be created using: 61 | * 62 | * ```js 63 | * const entity = world.create('myBeautifulEntity'); 64 | * ``` 65 | * 66 | * @category world 67 | */ 68 | export class World { 69 | /** @hidden */ 70 | protected readonly _components: ComponentManager; 71 | 72 | /** @hidden */ 73 | protected readonly _queries: QueryManager; 74 | 75 | /** @hidden */ 76 | protected readonly _systems: SystemManager; 77 | 78 | /** @hidden */ 79 | protected readonly _EntityClass: EntityClass>; 80 | 81 | /** @hidden */ 82 | protected _entityPool: Nullable>; 83 | 84 | /** Public API. */ 85 | 86 | public constructor(options: Partial> = {}) { 87 | const { 88 | maxComponentType = 256, 89 | useManualPooling = false, 90 | EntityClass = Entity, 91 | EntityPoolClass = DefaultPool, 92 | ComponentPoolClass = DefaultPool, 93 | systems = [], 94 | components = [] 95 | } = options; 96 | this._queries = new QueryManager(this); 97 | this._systems = new SystemManager(this); 98 | this._components = new ComponentManager(this, { 99 | maxComponentType, 100 | ComponentPoolClass: useManualPooling ? null : ComponentPoolClass 101 | }); 102 | this._EntityClass = EntityClass as EntityClass>; 103 | 104 | this._entityPool = null; 105 | if (useManualPooling) { 106 | this._entityPool = new EntityPoolClass( 107 | this._EntityClass 108 | ) as EntityPool; 109 | } 110 | 111 | for (const component of components) { 112 | this.registerComponent(component); 113 | } 114 | for (const system of systems) { 115 | this.register(system as SystemClass>); 116 | } 117 | } 118 | 119 | /** 120 | * Registers a system in this world instance 121 | * 122 | * **Note**: only one instance per system class can be registered 123 | * 124 | * @param Class - Class of the system to register 125 | * @param opts - Options forwarded to the constructor of the system 126 | * 127 | * @return This instance 128 | */ 129 | public register>( 130 | Class: SystemClass, 131 | opts?: SystemRegisterOptions 132 | ): this { 133 | this._systems.register(Class, opts); 134 | return this; 135 | } 136 | 137 | /** 138 | * Unregisters the system from this world instance 139 | * 140 | * ## Notes 141 | * 142 | * * Deletes the system and frees its queries if they aren't used by any 143 | * other systems 144 | * * Deletes the group in which the system was if the group is now empty 145 | * 146 | * @param Class - Class of the system to unregister 147 | * 148 | * @return This instance 149 | */ 150 | public unregister>(Class: SystemClass): this { 151 | this._systems.unregister(Class); 152 | return this; 153 | } 154 | 155 | /** 156 | * Registers a component type in this world instance. 157 | * 158 | * ## Notes 159 | * 160 | * It's not mandatory to pre-register a component this way. However, it's 161 | * always better to pre-allocate and initialize everything you can on startup 162 | * for optimal performance at runtime. 163 | * 164 | * Registering a component manually will avoid registration on first usage 165 | * and can thus optimize your runtime performance. 166 | * 167 | * @param Class - Class of the component to register 168 | * @param opts - Set of options to affect the component registration, such 169 | * as the pool used 170 | * 171 | * @return This instance 172 | */ 173 | public registerComponent( 174 | Class: ComponentClass, 175 | opts?: ComponentRegisterOptions 176 | ): this { 177 | this._components.registerComponentManual(Class, opts); 178 | return this; 179 | } 180 | 181 | /** 182 | * Creates a new entity 183 | * 184 | * @param name - Optional name of the entity. The name isn't a read-only 185 | * property and can be changed later 186 | * 187 | * @return The entity instance 188 | */ 189 | public create(name?: string): E { 190 | let entity; 191 | if (this._entityPool) { 192 | entity = this._entityPool.acquire(); 193 | entity._pooled = true; 194 | entity.name = name ?? null; 195 | } else { 196 | entity = new this._EntityClass(name); 197 | } 198 | entity['_world'] = this; 199 | this._components.initEntity(entity); 200 | return entity; 201 | } 202 | 203 | /** 204 | * Executes all systems groups, i.e., executes all registered systems 205 | * 206 | * @param delta - The delta time elapsed between the last call to `execute`, 207 | * in milliseconds 208 | */ 209 | public execute(delta: number): void { 210 | this._systems.execute(delta); 211 | } 212 | 213 | /** 214 | * ## Notes 215 | * 216 | * By default, entities aren't stored for fast retrieval with an identifier. 217 | * If you need this method to run fast, please create your own World class 218 | * and save entities based on their idenfitiers, i.e., 219 | * 220 | * ```js 221 | * class MyWorld extends World { 222 | * constructor(opts) { 223 | * super(opts); 224 | * this._entities = new Map(); 225 | * } 226 | * create(id?: string) { 227 | * const entity = super.create(id); 228 | * // You can also check for duplicates, etc... 229 | * this._entities.set(entity.id, entitiy); 230 | * return s 231 | * } 232 | * findById(id: string) { 233 | * // Do not call `super.findById()` here, you want to override the 234 | * // implementation. 235 | * return this._entities.get(id); 236 | * } 237 | * } 238 | * ``` 239 | * 240 | * @param {string} id 241 | * @returns {Option} 242 | * @memberof World 243 | */ 244 | public findByName(name: string): Nullable { 245 | return this._components.findEntityByName(name); 246 | } 247 | 248 | public setEntityPool(pool: Nullable>): this { 249 | this._entityPool = pool; 250 | return this; 251 | } 252 | 253 | public getEntityPool(): Option>> { 254 | return this._entityPool; 255 | } 256 | 257 | /** 258 | * Retrieves the system instance of type `Class` 259 | * 260 | * @param Class - Class of the system to retrieve 261 | * @return The system instance if found, `undefined` otherwise 262 | */ 263 | public system(Class: SystemClass): Option { 264 | return this._systems.system(Class); 265 | } 266 | 267 | /** 268 | * Retrieves the group instance of type `Class` 269 | * 270 | * @param Class - Class of the group to retrieve 271 | * @return The group instance if found, `undefined` otherwise 272 | */ 273 | public group(Class: SystemGroupClass): Option { 274 | return this._systems.group(Class); 275 | } 276 | 277 | public setComponentPool

>( 278 | Class: ComponentClass>, 279 | pool: Nullable

280 | ): this { 281 | this._components.setComponentPool(Class, pool); 282 | return this; 283 | } 284 | 285 | public getComponentPool( 286 | Class: ComponentClass 287 | ): Option>> { 288 | return this._components.getComponentPool(Class); 289 | } 290 | 291 | /** 292 | * Returns the unique identifier of the given component typee 293 | * 294 | * @param Class - Type of the component to retrieve the id for 295 | * @return The identifier of the component 296 | */ 297 | public getComponentId(Class: ComponentClass): number { 298 | return this._components.getIdentifier(Class); 299 | } 300 | 301 | /** 302 | * Returns the max number of components this world can store. 303 | * 304 | * **Note**: the number represents the count of component type (i.e., "class"), 305 | * and not the count of instance 306 | */ 307 | public get maxComponentTypeCount(): number { 308 | return this._components.maxComponentTypeCount; 309 | } 310 | 311 | /** Internal API. */ 312 | 313 | /** 314 | * @hidden 315 | */ 316 | public _onArchetypeCreated(archetype: Archetype>): void { 317 | this._queries.addArchetype(archetype); 318 | } 319 | 320 | /** 321 | * @hidden 322 | */ 323 | public _onArchetypeDestroyed(archetype: Archetype>): void { 324 | this._queries.removeArchetype(archetype); 325 | } 326 | 327 | /** 328 | * @hidden 329 | */ 330 | public _onQueryCreated(query: Query>): void { 331 | const archetypes = this._components.archetypes; 332 | archetypes.forEach((archetype) => 333 | this._queries.addArchetypeToQuery(query, archetype) 334 | ); 335 | } 336 | 337 | /** 338 | * @param {QueryComponents} components 339 | * @return todo 340 | * @hidden 341 | */ 342 | public _requestQuery(components: QueryComponents): Query> { 343 | return this._queries.request(components); 344 | } 345 | 346 | /** 347 | * @param {Query} query 348 | * @hidden 349 | */ 350 | public _releaseQuery(query: Query>): void { 351 | this._queries.release(query); 352 | } 353 | 354 | /** 355 | * @hidden 356 | * 357 | * ## Internals 358 | * 359 | * This method doesn't called `needArchetypeUpdate()` for performance 360 | * reasons. Here, we know the entity will simply be remove from its archetype, 361 | * and all its components will be disposed. 362 | * 363 | * @param entity - 364 | */ 365 | public _destroyEntityRequest(entity: EntityOf): void { 366 | this._components.destroyEntity(entity); 367 | if (entity.pooled) { 368 | this._entityPool?.release(entity); 369 | } 370 | } 371 | 372 | /** 373 | * @hidden 374 | */ 375 | public _addComponentRequest( 376 | entity: EntityOf, 377 | Class: ComponentClass, 378 | opts?: PropertiesOf 379 | ): void { 380 | this._components.addComponentToEntity(entity, Class, opts); 381 | } 382 | 383 | /** 384 | * @hidden 385 | */ 386 | public _removeComponentRequest( 387 | entity: EntityOf, 388 | Class: ComponentClass 389 | ): void { 390 | this._components.removeComponentFromEntity(entity, Class); 391 | } 392 | } 393 | 394 | /** 395 | * Options for the [[World]] constructor 396 | */ 397 | export interface WorldOptions { 398 | /** Default list of systems to register. */ 399 | 400 | systems: SystemClass[]; 401 | 402 | /** Default list of components to register. */ 403 | components: ComponentClass[]; 404 | 405 | /** 406 | * Number of components that will be registered. 407 | * 408 | * This is used for performance reasons. It's preferable to give the exact 409 | * amount of component type you are going to use, but it's OK to give an 410 | * inflated number if you don't fully know in advanced all components that 411 | * will be used. 412 | * 413 | * Default: 256 414 | */ 415 | maxComponentType: number; 416 | 417 | /** 418 | * If `true`, no pool is created by default for components and entities. 419 | * 420 | * Default: `false` 421 | */ 422 | useManualPooling: boolean; 423 | 424 | /** 425 | * Class of entity to instanciate when calling `world.create()`. 426 | * 427 | * **Note**: if you use your own entity class, please make sure it's 428 | * compatible with the default entity pool (if not using a custom pool). Try 429 | * to keep the same interface (constructor, methods, etc...) 430 | * 431 | * Default: [[Entity]] 432 | */ 433 | EntityClass: EntityClass; 434 | 435 | /** 436 | * Class of the default pool that will be used for components. 437 | * 438 | * Using you custom default pool allow you to perform fine-tuned logic to 439 | * improve pooling performance. 440 | * 441 | * ## Notes 442 | * 443 | * The pool will be instanciated by the world using: 444 | * 445 | * ```js 446 | * const pool = new ComponentPoolClass(ComponentType); 447 | * ``` 448 | * 449 | * Please ensure that your interface is compatible 450 | * 451 | * Default: [[DefaultPool]] 452 | */ 453 | ComponentPoolClass: Constructor>; 454 | 455 | /** 456 | * Class of the default pool that will be used for entities. 457 | * 458 | * Using you custom default pool allow you to perform fine-tuned logic to 459 | * improve pooling performance. 460 | * 461 | * ## Notes 462 | * 463 | * The pool will be instanciated by the world using: 464 | * 465 | * ```js 466 | * const pool = new EntityPoolClass(EntityClass); 467 | * ``` 468 | * 469 | * Please ensure that your interface is compatible 470 | * 471 | * Default: [[DefaultPool]] 472 | */ 473 | EntityPoolClass: Constructor>; 474 | } 475 | 476 | export interface SystemRegisterOptions { 477 | group?: Constructor>; 478 | order?: number; 479 | } 480 | 481 | type EntityPool = ObjectPool>; 482 | -------------------------------------------------------------------------------- /test/unit/component.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { ComponentData } from '../../src/component.js'; 4 | import { boolean, number, string, array, ref } from '../../src/decorators.js'; 5 | import { ArrayProp, BooleanProp } from '../../src/property.js'; 6 | import { World } from '../../src/world.js'; 7 | import { FooComponent } from './utils.js'; 8 | 9 | test('Component > ComponentData > Properties created', (t) => { 10 | const world = new World(); 11 | let entity = world.create(); 12 | 13 | class TestComponent extends ComponentData { 14 | static Properties = { 15 | myBoolean: BooleanProp(), 16 | myArray: ArrayProp() 17 | }; 18 | myBoolean!: boolean; 19 | myArray!: any[]; 20 | } 21 | 22 | entity.add(TestComponent); 23 | const comp = entity.read(TestComponent)!; 24 | t.true(comp.myBoolean !== undefined); 25 | t.true(comp.myArray !== undefined); 26 | 27 | // Same test, but with decorators instead of manual properties. 28 | 29 | class TestComponentDecorator extends ComponentData { 30 | @boolean() 31 | myBoolean!: boolean; 32 | } 33 | 34 | entity = world.create(); 35 | entity.add(TestComponentDecorator); 36 | const compDecorator = entity.read(TestComponentDecorator)!; 37 | t.true(compDecorator.myBoolean !== undefined); 38 | }); 39 | 40 | test('Component > ComponentData > copy', (t) => { 41 | const world = new World(); 42 | const entity = world.create(); 43 | 44 | entity.add(FooComponent); 45 | const component = entity.read(FooComponent)!; 46 | t.true(component.isFoo); 47 | t.is(component.count, 1); 48 | t.is(component.dummy, 'dummy'); 49 | 50 | component.copy({ 51 | isFoo: false, 52 | count: 100 53 | }); 54 | t.is(component.isFoo, false); 55 | t.is(component.count, 100); 56 | 57 | const source = new FooComponent(); 58 | source.count = -1; 59 | source.dummy = 'notdummy'; 60 | component.copy(source); 61 | t.is(component.count, -1); 62 | t.is(component.dummy, 'notdummy'); 63 | }); 64 | 65 | test('Component > ComponentData > Decorators', (t) => { 66 | const obj = { foo: 'foo', bar: 'bar' }; 67 | 68 | // Test component with default value set in class. 69 | 70 | class TestComponentDecorator extends ComponentData { 71 | @boolean(true) 72 | myBoolean!: boolean; 73 | @number(100) 74 | myNumber!: number; 75 | @string('hello') 76 | myString!: string; 77 | @array(['defaultStr1', 'defaultStr2']) 78 | myArray!: string[]; 79 | @ref(obj) 80 | myRef!: { foo: string; bar: string } | null; 81 | } 82 | 83 | const component = new TestComponentDecorator(); 84 | t.is(component.myBoolean, true); 85 | t.is(component.myNumber, 100); 86 | t.is(component.myString, 'hello'); 87 | t.deepEqual(component.myArray, ['defaultStr1', 'defaultStr2']); 88 | t.is(component.myRef, obj); 89 | 90 | component.myNumber = Number.POSITIVE_INFINITY; 91 | component.init({}); 92 | t.is(component.myNumber, 100); 93 | }); 94 | -------------------------------------------------------------------------------- /test/unit/entity.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { World } from '../../src/world.js'; 4 | import { BarComponent, FooComponent } from './utils.js'; 5 | 6 | test('Entity - create entity default', (t) => { 7 | const world = new World(); 8 | 9 | const entity = world.create(); 10 | t.is(entity.name, null); 11 | t.is(entity._components.size, 0); 12 | 13 | const entityB = world.create('coolentity'); 14 | t.is(entityB.name, 'coolentity'); 15 | t.is(entityB._components.size, 0); 16 | }); 17 | 18 | test('Entity - add component', (t) => { 19 | const world = new World(); 20 | const entity = world.create(); 21 | t.is(entity['_archetype'], world['_components']['_emptyArchetype']); 22 | 23 | t.true(entity.isEmpty); 24 | t.deepEqual(entity.componentClasses, []); 25 | 26 | entity.add(FooComponent); 27 | t.false(entity.isEmpty); 28 | t.true(entity.has(FooComponent)); 29 | t.deepEqual(entity.componentClasses, [FooComponent]); 30 | t.true(entity.read(FooComponent)!.constructor === FooComponent); 31 | 32 | entity.add(BarComponent); 33 | t.true(entity.has(BarComponent)); 34 | t.deepEqual(entity.componentClasses, [FooComponent, BarComponent]); 35 | t.true(entity.read(BarComponent)!.constructor === BarComponent); 36 | }); 37 | 38 | test('Entity - remove component', (t) => { 39 | const world = new World(); 40 | const entity = world.create(); 41 | t.true(entity.isEmpty); 42 | t.deepEqual(entity.componentClasses, []); 43 | 44 | entity.add(FooComponent); 45 | t.false(entity.isEmpty); 46 | 47 | entity.remove(FooComponent); 48 | t.true(entity.isEmpty); 49 | t.false(entity.has(FooComponent)); 50 | 51 | entity.add(BarComponent).add(FooComponent); 52 | t.true(entity.has(FooComponent)); 53 | t.true(entity.has(BarComponent)); 54 | 55 | entity.remove(BarComponent); 56 | t.true(entity.has(FooComponent)); 57 | t.false(entity.has(BarComponent)); 58 | }); 59 | 60 | test('Entity - destroy', (t) => { 61 | const world = new World(); 62 | const entity = world.create('a').add(FooComponent).add(BarComponent); 63 | world.create('b').add(FooComponent); 64 | world.create('b').add(BarComponent); 65 | 66 | // Assumes entity are destroyed synchronously 67 | const archetypeId = entity.archetype!.hash; 68 | entity.destroy(); 69 | t.is(entity.archetype, null); 70 | t.false(world['_components'].archetypes.has(archetypeId)); 71 | }); 72 | -------------------------------------------------------------------------------- /test/unit/pooling.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { Entity } from '../../src/entity.js'; 4 | import { DefaultPool } from '../../src/pool.js'; 5 | import { World } from '../../src/world.js'; 6 | import { FooComponent } from './utils.js'; 7 | 8 | test('Pooling > grow on acquire', (t) => { 9 | const pool = new DefaultPool(Entity); 10 | t.is(pool.allocatedSize, 0); 11 | pool.acquire(); 12 | t.true(pool.allocatedSize > 0); 13 | }); 14 | 15 | test('Pooling > acquire re-use free slots', (t) => { 16 | const pool = new DefaultPool(Entity); 17 | t.is(pool.allocatedSize, 0); 18 | const value = pool.acquire(); 19 | t.is(pool.used, 1); 20 | pool.release(value); 21 | t.is(pool.used, 0); 22 | t.is(value, pool.acquire()); 23 | }); 24 | 25 | test('Pooling > over-release does not bring the pool into UB', (t) => { 26 | const pool = new DefaultPool(Entity); 27 | const value = pool.acquire(); 28 | for (let i = 0; i < 100; ++i) { 29 | pool.release(value); 30 | } 31 | t.is(pool.used, 0); 32 | t.is(value, pool.acquire()); 33 | t.is(pool.used, 1); 34 | }); 35 | 36 | test('Pooling > Component > add & release component', (t) => { 37 | const world = new World({ 38 | useManualPooling: false 39 | }); 40 | const entity = world.create(); 41 | entity.add(FooComponent); 42 | const ref = entity.read(FooComponent); 43 | entity.remove(FooComponent); 44 | entity.add(FooComponent); 45 | 46 | const entityB = world.create(); 47 | entityB.add(FooComponent); 48 | const entityC = world.create(); 49 | entityC.add(FooComponent); 50 | 51 | entity.remove(FooComponent); 52 | entity.add(FooComponent); 53 | t.is(ref, entity.read(FooComponent)); 54 | }); 55 | -------------------------------------------------------------------------------- /test/unit/property.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { 4 | ArrayProp, 5 | BooleanProp, 6 | CopyableProp, 7 | CopyClonableType, 8 | NumberProp, 9 | RefProp, 10 | StringProp 11 | } from '../../src/property.js'; 12 | 13 | test('Property > primitives', (t) => { 14 | t.is(NumberProp().default, 0); 15 | t.is(NumberProp(1).default, 1); 16 | 17 | t.false(BooleanProp().default); 18 | t.true(BooleanProp(true).default); 19 | 20 | t.is(StringProp().default, ''); 21 | t.is(StringProp('Hello World!').default, 'Hello World!'); 22 | 23 | const o = {}; 24 | t.is(RefProp().default, null); 25 | t.is(RefProp(o).default, o); 26 | t.not(RefProp(o).copy(o, {}), o); 27 | }); 28 | 29 | test('Property > array', (t) => { 30 | t.deepEqual(ArrayProp().default, []); 31 | t.deepEqual(ArrayProp(['hello', 'world']).default, ['hello', 'world']); 32 | 33 | const p = ArrayProp(['hello', 'world']); 34 | t.deepEqual(p.default, p.cloneDefault()); 35 | t.deepEqual(p.default, p.copy(p.default, [])); 36 | t.not(p.default, p.cloneDefault()); // Reference shouldn't be identical. 37 | }); 38 | 39 | test('Property > copyable', (t) => { 40 | class Copyable implements CopyClonableType { 41 | a: number; 42 | b: number; 43 | constructor(a?: number, b?: number) { 44 | this.a = a ?? 0; 45 | this.b = b ?? 0; 46 | } 47 | copy(source: Copyable): this { 48 | this.a = source.a; 49 | this.b = source.b; 50 | return this; 51 | } 52 | clone(): this { 53 | return new Copyable().copy(this) as this; 54 | } 55 | } 56 | const prop = CopyableProp({ type: Copyable, default: new Copyable(1, 2) }); 57 | t.is(prop.default.a, 1); 58 | t.is(prop.default.b, 2); 59 | const cp = prop.copy(new Copyable(), prop.default); 60 | t.is(cp.a, 1); 61 | t.is(cp.b, 2); 62 | }); 63 | -------------------------------------------------------------------------------- /test/unit/query.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { TagComponent } from '../../src/component.js'; 3 | import { Archetype } from '../../src/internals/archetype.js'; 4 | import { Not, Query } from '../../src/query.js'; 5 | import { System, SystemOptions } from '../../src/system.js'; 6 | import { SystemGroup } from '../../src/system-group'; 7 | import { World } from '../../src/world.js'; 8 | import { 9 | BarComponent, 10 | FooBarSystem, 11 | FooComponent, 12 | spy, 13 | SpyFunction 14 | } from './utils.js'; 15 | import { Entity } from '../../src/entity.js'; 16 | 17 | test('Query > archetype match', (t) => { 18 | class MyTagComponent extends TagComponent {} 19 | const query = new Query('', [FooComponent, BarComponent]); 20 | t.true(query.matches(new Archetype([BarComponent, FooComponent], ''))); 21 | t.true( 22 | query.matches( 23 | new Archetype([BarComponent, FooComponent, MyTagComponent], '') 24 | ) 25 | ); 26 | t.false(query.matches(new Archetype([FooComponent], ''))); 27 | t.false(query.matches(new Archetype([BarComponent], ''))); 28 | }); 29 | 30 | test('Query > archetype match (not) operator', (t) => { 31 | class MyTagComponent extends TagComponent {} 32 | const query = new Query('', [ 33 | FooComponent, 34 | BarComponent, 35 | Not(MyTagComponent) 36 | ]); 37 | t.true(query.matches(new Archetype([BarComponent, FooComponent], ''))); 38 | t.false( 39 | query.matches( 40 | new Archetype([BarComponent, FooComponent, MyTagComponent], '') 41 | ) 42 | ); 43 | }); 44 | 45 | test('Query Manager > intersection', (t) => { 46 | class MyTagComponent extends TagComponent {} 47 | class MySystem extends System { 48 | public static Queries = { 49 | foobar: [FooComponent, BarComponent], 50 | all: [FooComponent, BarComponent, MyTagComponent] 51 | }; 52 | execute() { 53 | /** Empty. */ 54 | } 55 | } 56 | 57 | const world = new World().register(MySystem, {}); 58 | const system = world.system(MySystem)!; 59 | 60 | const entityA = world 61 | .create('foobar_entity') 62 | .add(FooComponent) 63 | .add(BarComponent); 64 | const entityB = world 65 | .create('foobartag_entity') 66 | .add(FooComponent) 67 | .add(BarComponent) 68 | .add(MyTagComponent); 69 | const entityC = world.create('foo_entity').add(FooComponent); 70 | 71 | // Assumes adding component is synchronous. 72 | 73 | t.true(system['queries'].foobar.hasEntity(entityA)); 74 | t.true(system['queries'].foobar.hasEntity(entityB)); 75 | t.false(system['queries'].foobar.hasEntity(entityC)); 76 | 77 | t.true(system['queries'].all.hasEntity(entityB)); 78 | t.false(system['queries'].all.hasEntity(entityA)); 79 | t.false(system['queries'].all.hasEntity(entityC)); 80 | }); 81 | 82 | test('Query Manager > register system when archetypes already exist', (t) => { 83 | class MySystem extends System { 84 | public static Queries = { 85 | q: [FooComponent] 86 | }; 87 | execute() {} 88 | } 89 | 90 | const world = new World(); 91 | const entity = world 92 | .create('foobar_entity') 93 | .add(FooComponent) 94 | .add(BarComponent); 95 | 96 | // Assumes adding component is synchronous. 97 | world.register(MySystem, {}); 98 | const system = world.system(MySystem)!; 99 | t.true(system['queries'].q.hasEntity(entity)); 100 | }); 101 | 102 | test('Query Manager > `not` selector', (t) => { 103 | class MySystem extends System { 104 | public static Queries = { 105 | foobar: [FooComponent, Not(BarComponent)] 106 | }; 107 | execute() {} 108 | } 109 | 110 | const world = new World().register(MySystem, {}); 111 | const system = world.system(MySystem)!; 112 | 113 | const entityA = world.create('foobar_entity').add(FooComponent); 114 | const entityB = world 115 | .create('foobartag_entity') 116 | .add(FooComponent) 117 | .add(BarComponent); 118 | 119 | // Assumes adding component is synchronous. 120 | t.true(system['queries'].foobar.hasEntity(entityA)); 121 | t.false(system['queries'].foobar.hasEntity(entityB)); 122 | }); 123 | 124 | test('Query Manager > component added / removed', (t) => { 125 | const world = new World().register(FooBarSystem, {}); 126 | const system = world.system(FooBarSystem)!; 127 | const entity = world.create().add(FooComponent); 128 | // Assumes adding component is synchronous. 129 | t.false(system['queries'].foobar.hasEntity(entity)); 130 | entity.add(BarComponent); 131 | t.true(system['queries'].foobar.hasEntity(entity)); 132 | entity.remove(FooComponent); 133 | // Assumes removing component is synchronous. 134 | t.false(system['queries'].foobar.hasEntity(entity)); 135 | }); 136 | 137 | test('Query Manager > entity destroyed', (t) => { 138 | const world = new World().register(FooBarSystem, {}); 139 | const system = world.system(FooBarSystem)!; 140 | const entity = world.create().add(FooComponent).add(BarComponent); 141 | // Assumes adding component is synchronous. 142 | t.true(system['queries'].foobar.hasEntity(entity)); 143 | entity.destroy(); 144 | // Assumes destroying entity is synchronous. 145 | t.false(system['queries'].foobar.hasEntity(entity)); 146 | }); 147 | 148 | test('Query Manager > added entity triggers query callback', (t) => { 149 | class MySystem extends System { 150 | public static Queries = { 151 | foobar: [FooComponent] 152 | }; 153 | constructor(group: SystemGroup, options: Partial) { 154 | super(group, options); 155 | this.queries.foobar.onEntityAdded = spy(); 156 | } 157 | execute() { 158 | /** Empty. */ 159 | } 160 | } 161 | 162 | const world = new World().register(MySystem, {}); 163 | const system = world.system(MySystem) as MySystem; 164 | 165 | const added = system['queries'].foobar.onEntityAdded as SpyFunction; 166 | t.false(added.called); 167 | world.create().add(FooComponent); 168 | t.true(added.called); 169 | t.is(added.calls.length, 1); 170 | 171 | const entityA = world.create(); 172 | 173 | t.is(added.calls.length, 1); 174 | entityA.add(FooComponent); 175 | t.is(added.calls.length, 2); 176 | }); 177 | 178 | test('Query Manager > removed entity triggers query callback', (t) => { 179 | class MySystem extends System { 180 | public static Queries = { 181 | foobar: [FooComponent] 182 | }; 183 | constructor(group: SystemGroup, options: Partial) { 184 | super(group, options); 185 | this.queries.foobar.onEntityRemoved = spy(); 186 | } 187 | execute() { 188 | /** Empty. */ 189 | } 190 | } 191 | 192 | const world = new World().register(MySystem, {}); 193 | const system = world.system(MySystem) as MySystem; 194 | 195 | const removed = system['queries'].foobar.onEntityRemoved as SpyFunction; 196 | t.false(removed.called); 197 | const entityA = world.create().add(FooComponent); 198 | t.is(removed.calls.length, 0); 199 | 200 | entityA.remove(FooComponent); 201 | t.is(removed.calls.length, 1); 202 | entityA.add(FooComponent).destroy(); 203 | t.is(removed.calls.length, 2); 204 | }); 205 | 206 | test('Query Manager > entity move to new archetype triggers query callback', (t) => { 207 | class MySystem extends System { 208 | public static Queries = { 209 | foo: [FooComponent], 210 | foobar: [FooComponent, BarComponent] 211 | }; 212 | constructor(group: SystemGroup, options: Partial) { 213 | super(group, options); 214 | this.queries.foo.onEntityAdded = spy(); 215 | this.queries.foo.onEntityRemoved = spy(); 216 | this.queries.foobar.onEntityAdded = spy(); 217 | this.queries.foobar.onEntityRemoved = spy(); 218 | } 219 | execute() { 220 | /** Empty. */ 221 | } 222 | } 223 | 224 | const world = new World().register(MySystem, {}); 225 | const system = world.system(MySystem) as MySystem; 226 | 227 | t.is((system.queries.foo.onEntityAdded as SpyFunction).calls.length, 0); 228 | t.is((system.queries.foo.onEntityRemoved as SpyFunction).calls.length, 0); 229 | t.is((system.queries.foobar.onEntityAdded as SpyFunction).calls.length, 0); 230 | t.is((system.queries.foobar.onEntityRemoved as SpyFunction).calls.length, 0); 231 | 232 | const entity = world.create().add(FooComponent); 233 | t.is((system.queries.foo.onEntityAdded as SpyFunction).calls.length, 1); 234 | entity.add(BarComponent); 235 | t.is((system.queries.foo.onEntityRemoved as SpyFunction).calls.length, 1); 236 | t.is((system.queries.foobar.onEntityAdded as SpyFunction).calls.length, 1); 237 | }); 238 | 239 | test('Query Manager > clear archetype observers when query is deleted', (t) => { 240 | class MySystem extends System { 241 | public static Queries = { 242 | foo: [FooComponent] 243 | }; 244 | constructor(group: SystemGroup, options: Partial) { 245 | super(group, options); 246 | } 247 | execute() { 248 | /** Empty. */ 249 | } 250 | } 251 | 252 | const world = new World().register(MySystem, {}); 253 | const entity = world.create().add(FooComponent); 254 | const archetype = entity.archetype as Archetype; 255 | 256 | t.is(archetype.onEntityAdded.count, 1); 257 | t.is(archetype.onEntityRemoved.count, 1); 258 | entity.remove(FooComponent); 259 | t.is(archetype.onEntityAdded.count, 0); 260 | t.is(archetype.onEntityRemoved.count, 0); 261 | }); 262 | -------------------------------------------------------------------------------- /test/unit/system.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | 3 | import { SystemGroup } from '../../src/system-group.js'; 4 | import { System } from '../../src/system.js'; 5 | import { World } from '../../src/world.js'; 6 | import { before, after } from '../../src/decorators.js'; 7 | 8 | test('SystemGroup > topological sorting', (t) => { 9 | const world = new World(); 10 | const group = new SystemGroup(world); 11 | 12 | class SystemC extends System { 13 | execute() {} 14 | } 15 | 16 | @after([SystemC]) 17 | class SystemD extends System { 18 | execute() {} 19 | } 20 | 21 | @after([SystemC]) 22 | @before([SystemD]) 23 | class SystemA extends System { 24 | execute() {} 25 | } 26 | 27 | @before([SystemA]) 28 | class SystemB extends System { 29 | execute() {} 30 | } 31 | 32 | const a = new SystemA(group, {}); 33 | const b = new SystemB(group, {}); 34 | const c = new SystemC(group, {}); 35 | const d = new SystemD(group, {}); 36 | group.add(a); 37 | group.add(b); 38 | group.add(c); 39 | group.add(d); 40 | t.deepEqual(group['_systems'], [a, b, c, d]); 41 | group.sort(); 42 | t.deepEqual(group['_systems'], [c, b, a, d]); 43 | }); 44 | 45 | test('SystemGroup > topological sorting cycle', (t) => { 46 | const world = new World(); 47 | const group = new SystemGroup(world); 48 | 49 | class SystemC extends System { 50 | execute() {} 51 | } 52 | 53 | @after([SystemC]) 54 | class SystemD extends System { 55 | execute() {} 56 | } 57 | 58 | @after([SystemD]) 59 | @before([SystemC]) 60 | class SystemA extends System { 61 | execute() {} 62 | } 63 | const a = new SystemA(group, {}); 64 | const c = new SystemC(group, {}); 65 | const d = new SystemD(group, {}); 66 | group.add(c); 67 | group.add(a); 68 | group.add(d); 69 | t.deepEqual(group['_systems'], [c, a, d]); 70 | group.sort(); 71 | // In the case of a cycle, the final ordering will be dependant on the 72 | // insert order. 73 | t.deepEqual(group['_systems'], [c, d, a]); 74 | }); 75 | 76 | test('SystemGroup > ordering', (t) => { 77 | const world = new World(); 78 | const group = new SystemGroup(world); 79 | 80 | class SystemA extends System { 81 | execute() {} 82 | } 83 | class SystemB extends System { 84 | execute() {} 85 | } 86 | class SystemC extends System { 87 | execute() {} 88 | } 89 | const a = new SystemA(group, { order: -100 }); 90 | const c = new SystemC(group, { order: 1 }); 91 | const b = new SystemB(group, { order: 200 }); 92 | group.add(b); 93 | group.add(a); 94 | group.add(c); 95 | t.deepEqual(group['_systems'], [b, a, c]); 96 | group.sort(); 97 | t.deepEqual(group['_systems'], [a, c, b]); 98 | }); 99 | -------------------------------------------------------------------------------- /test/unit/utils.ts: -------------------------------------------------------------------------------- 1 | import { ComponentData } from '../../src/component.js'; 2 | import { BooleanProp, NumberProp, StringProp } from '../../src/property.js'; 3 | import { System } from '../../src/system.js'; 4 | 5 | export class FooComponent extends ComponentData { 6 | public static Name = 'Foo'; 7 | 8 | public static Properties = { 9 | isFoo: BooleanProp(true), 10 | count: NumberProp(1), 11 | dummy: StringProp('dummy') 12 | }; 13 | 14 | public isFoo!: boolean; 15 | public count!: number; 16 | public dummy!: string; 17 | } 18 | 19 | export class BarComponent extends ComponentData { 20 | public static Name = 'Bar'; 21 | 22 | public static Properties = { 23 | isBar: BooleanProp(true) 24 | }; 25 | 26 | public isBar!: boolean; 27 | } 28 | 29 | export class FooBarSystem extends System { 30 | public static Queries = { 31 | foobar: [FooComponent, BarComponent] 32 | }; 33 | execute(): void { 34 | /** Empty. */ 35 | } 36 | } 37 | 38 | export function spy(): SpyFunction { 39 | function proxy(...args: unknown[]) { 40 | proxy.calls.push(args); 41 | proxy.called = true; 42 | } 43 | proxy.calls = [] as unknown[]; 44 | proxy.called = false; 45 | return proxy; 46 | } 47 | 48 | export interface SpyFunction { 49 | (): unknown; 50 | calls: unknown[]; 51 | called: boolean; 52 | } 53 | -------------------------------------------------------------------------------- /test/unit/world.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { SystemGroup } from '../../src/system-group.js'; 3 | import { System } from '../../src/system.js'; 4 | import { World } from '../../src/world.js'; 5 | import { spy } from './utils.js'; 6 | 7 | test('World > System > register', (t) => { 8 | class MySystem extends System { 9 | execute() {} 10 | init = spy(); 11 | } 12 | const world = new World(); 13 | world.register(MySystem); 14 | const sys = world.system(MySystem)!; 15 | 16 | t.true(!!sys); 17 | t.is(sys.group.constructor, SystemGroup); 18 | t.true(sys.init.called); 19 | }); 20 | 21 | test('World > System > register with group', (t) => { 22 | class MySystem extends System { 23 | execute() {} 24 | } 25 | class MyOtherSystem extends System { 26 | execute() {} 27 | } 28 | class MyGroup extends SystemGroup {} 29 | class MyOtherGroup extends SystemGroup {} 30 | 31 | const world = new World(); 32 | world 33 | .register(MySystem, { group: MyGroup }) 34 | .register(MyOtherSystem, { group: MyOtherGroup }); 35 | t.is(world.system(MySystem)!.group.constructor, MyGroup); 36 | t.is(world.system(MyOtherSystem)!.group.constructor, MyOtherGroup); 37 | }); 38 | 39 | test('World > System > retrieve', (t) => { 40 | class MySystem extends System { 41 | execute() {} 42 | } 43 | const world = new World(); 44 | world.register(MySystem); 45 | t.is(world.system(MySystem)!.constructor, MySystem); 46 | }); 47 | 48 | test('World > SystemGroup > retrieve', (t) => { 49 | class MySystem extends System { 50 | execute() {} 51 | } 52 | class MyGroup extends SystemGroup {} 53 | const world = new World(); 54 | world.register(MySystem, { group: MyGroup }); 55 | t.is(world.system(MySystem)!.group.constructor, MyGroup); 56 | }); 57 | 58 | test('World > System > unregister', (t) => { 59 | class MySystem extends System { 60 | execute() { 61 | /** Empty. */ 62 | } 63 | dispose = spy(); 64 | } 65 | class MyGroup extends SystemGroup {} 66 | 67 | const world = new World(); 68 | world.register(MySystem, { group: MyGroup }); 69 | 70 | const system = world.system(MySystem)!; 71 | const group = world.group(MyGroup)!; 72 | 73 | t.true(!!world.system(MySystem)!); 74 | t.is(system.group.constructor, MyGroup); 75 | 76 | world.unregister(MySystem); 77 | t.false(!!world.system(MySystem)!); 78 | t.is(group['_systems'].indexOf(system), -1); 79 | t.is(world.group(MyGroup), undefined); 80 | t.true(system.dispose.called); 81 | }); 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "outDir": "./dist", 7 | "declaration": true, 8 | "sourceMap": false, 9 | "allowJs": false, 10 | "newLine": "LF", 11 | "strict": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "noEmitOnError": true, 16 | "strictFunctionTypes": true, 17 | "skipLibCheck": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "stripInternal": true, 20 | "allowSyntheticDefaultImports": true, 21 | "experimentalDecorators": true 22 | }, 23 | 24 | "include": [ "./src" ], 25 | 26 | "exclude": [ "node_modules" ] 27 | } 28 | --------------------------------------------------------------------------------