Repository: mayakwd/tick-knock Branch: develop Commit: 48e16b69a643 Files: 31 Total size: 174.2 KB Directory structure: gitextract_slnklggk/ ├── .github/ │ └── workflows/ │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest-ci.json ├── package.json ├── src/ │ ├── ecs/ │ │ ├── ComponentId.ts │ │ ├── Engine.ts │ │ ├── Entity.ts │ │ ├── IterativeSystem.ts │ │ ├── LinkedComponent.ts │ │ ├── LinkedComponentList.ts │ │ ├── Query.ts │ │ ├── ReactionSystem.ts │ │ ├── Subscription.ts │ │ ├── System.ts │ │ └── Tag.ts │ ├── index.ts │ └── utils/ │ ├── Class.ts │ └── Signal.ts ├── tests/ │ └── unit/ │ ├── engine.spec.ts │ ├── entity.spec.ts │ ├── linked.list.spec.ts │ ├── query.spec.ts │ ├── shared.config.spec.ts │ ├── signal.spec.ts │ └── system.spec.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: workflow_dispatch: push: branches: [ $default-branch ] pull_request: branches: [ $default-branch ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [ 20 ] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'yarn' - name: Install dependencies run: yarn install - name: Build run: yarn build - name: Run tests run: yarn test-ci ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish to NPM on: push: branches: - main - master tags: - v* jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Use Node.js 20 uses: actions/setup-node@v4 with: node-version: 20 cache: 'yarn' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: yarn install - name: Run tests run: yarn test-ci publish-to-npm: runs-on: ubuntu-latest strategy: matrix: node-version: [ 20 ] needs: build steps: - uses: actions/checkout@v4 - name: Use Node.js 20 uses: actions/setup-node@v4 with: node-version: 20 cache: 'yarn' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: yarn install - name: Build run: yarn build - name: Run tests run: yarn test-ci - name: Pack run: yarn pack - name: Publish to NPM run: yarn publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # next.js build output .next # Generated library lib ================================================ FILE: .travis.yml ================================================ language: node_js dist: focal node_js: - "19" before_install: - curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.15.2 - export PATH=$HOME/.yarn/bin:$PATH install: - yarn cache: yarn: true before_script: - yarn global add codecov - yarn setup - yarn build script: - yarn test-ci after_success: - codecov - yarn pack deploy: - provider: npm edge: true cleanup: false email: "contact@pastila.org" api_key: $NPM_TOKEN on: tags: true ================================================ FILE: CHANGELOG.md ================================================ # 4.3.0 Features: - Introduced possibility to safely remove entities from the engine. Now `Engine.removeEntity` takes a boolean value as a second argument "safe", which indicates whether the entity should be removed safely or not. If safe argument value is `true` then the entity will be removed after the Engine update cycle is iteration is finished, meaning that the entity will be removed after all systems have been updated. Safely removed entities won't be discoverable by getEntityById method, but they will be still accessible in the queries (and remaining systems updates). This behavior will become default in the next major release. # 4.2.0 Features: - Now you can request for system removal when it's no longer needed. Check `requestRemoval`. # 4.1.0 Fixes: - \[Breaking Change\] Arguments order of `pick` by id API aligned with other APIs. - `isLinkedComponent` now returns false for undefined values, instead of throwing an Error. # 4.0.5 Features: - The following APIs got an additional optional id parameter to make working with Linked Components easier: `has` , `hasComponent`, `get`. # 4.0.4 Fixes: - `id` of `LinkedComponent` is not readonly anymore. # 4.0.3 Features: - Linked components now have an optional id, and can be picked with `pick` by id. Fixes: - Fixed usage sample for LinkedComponents # 4.0.2 Fixes: - If IterativeSystem was removed from the engine and added again later, no iteration took place. # 4.0.1 Fixes: - ReactionSystem now exported through index.ts # 4.0.0 Features: - Added a new convenient API for working with linked components: - Method `withdraw` removes the first LinkedComponent component of the provided type or existing standard component - Method `pick` removes provided LinkedComponent component instance or existing standard component - Method `iterate` iterates over instances of LinkedComponent and performs the `action` over each. Works for standard components (action will be called for a single instance in this case). - Method `find` searches a component instance of the specified class. Works for standard components (predicate will be called for a single instance in this case). - Method `getAll` returns a generator that can be used for iteration over all instances of specific type components. - Method `lengthOf` returns the number of existing components of the specified class. Breaking changes: - Signals `onComponentAdded`, `onComponentRemoved` now will be triggered for every LinkedComponent. - Adding a linked component with `add` or `addComponent` will remove all existing linked components of the same type. Linked components will be replaced even if the passed component already exists in the Entity. # 3.0.1 Fixes: - `EntitySnapshot.current` now is writable. - Added inline documentation to `EntitySnapshot.previous`. # 3.0.0 Features: - Added shared config entity, that is accessible across all systems added to `Engine` - Added possibility to retrieve `Entity` from `Engine` by id Breaking changes: - Parameter `engine` was removed from `onAddedToEngine` and `onRemovedFromEngine` methods in the systems. Use `this.engine` instead. - `EntitySnapshot` was reimplemented. It has distinguished fields `EntitySnapshot.current and `EntitySnapshot.previous`, which reflects current and previous Entity states accordingly. - `Entity.components` now represented as a `Record` instead of the `Map` Improvements: - Typed-signals was replaced with the built-in light-weight implementation. - `EntitySnapshot` won't be created if there are no change listeners. Fixes: - `Entity.copyFrom` now copies tags. - `EntitySnapshot` now works properly with the tags. Previously, the difference between the previous state and the current state did not show changes in the tags. - `EntitySnapshot` now works properly with the resolveClass. # 2.2.0 Features: - Add linked components Fixed: - Documentation readability # 2.1.0 Features: - Add possibility to set any type as the message type for subscription # 2.0.2 - Fixed broken Class API # 2.0.1 - Fixed broken API for QueryBuilder and Entity.remove # 2.0.0 - Added tags support - Added messaging channel for system->engine->user - Fixed EntitySnapshot behavior - Added `engine` getter in the System - Added support of initialization ReactionSystem and IterativeSystem with QueryPredicate and QueryBuilder - Query got possibility to check whether entity is in it, via `has` method - Documentation completely rewritten # 1.4.1 - Removed redundant `updateEntity` from `ReactionSystem` # 1.4.0 - Added `ReactionSystem` - Documentation updated # 1.3.0 - Fixed critical issue with updating of a `Query`. Queries whose predicates were a set of conditions that went beyond the capabilities of QueryBuilder could incorrectly evaluate the presence state for Entity after removing or adding components. # 1.2.7 - Fixed wrong type inference for `Entity#hasAll` and `Entity#hasAny` - Added several utility methods for `Query` # 1.2.6 - Added `first`, `last` and `length` getter for queries # 1.2.5 - Added feature of invalidation entity and queries - Fixed disconnecting of entities from engine # 1.2.4 - Switched to commonjs modules # 1.2.3 - Reverted `IterativeSystem#entities` remove - Added `IterativeSystem#prepare` protected method, which will be invoked after adding iterative system to engine # 1.2.2 - Added Entity#hasAny, Entity#hasAll methods - Fixed throwing an error with passing invalid value to param `component` of `Entity#add` method - Removed redundant `entities` getter from `IterativeSystem` # 1.2.1 - Fixed bug with disconnecting from Entity events after remove from Engine. - Added utility methods for clearing `Engine`. - `Engine#clear()` - `Engine#removeAllSystems()` - `Engine#removeAllQueries()` - `Engine#removeAllEntities()` # 1.2.0 - Changed logic of resolving of component identifier. Changes could affect resolving of inherited components. Now inherited components will not be resolved as its ancestors. - Added parameter for Entity#add "resolveClass" - which specifies how could be resolved component. - Updated documentation - Added tests for Query#isEmpty # 1.1.2 - Added Query#isEmpty property # 1.1.1 - Added documentation # 1.1.0 - Fixed query onEntityAdded, onEntityRemoved handlers - Added entity snapshot for properly handling of the entity changes # 1.0.7 - Fixed false-positive query trigger # 1.0.6 - Switched library target to ES5 # 1.0.5 - Updated documentation for every core type - Added guard that stops updating process for IterativeSystem, if it was removed engine - Fixed order of dispatching and removing of the component. Now dispatching happens before removing. - Added "get accessor" to query entities from Iterative system # 1.0.0 - Initial release ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019-2020 Ilya Malanin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Tick-Knock > Small and powerful, type-safe and easy-to-use Entity-Component-System (ECS) > library written in TypeScript [![Build Status](https://github.com/mayakwd/tick-knock/actions/workflows/build.yml/badge.svg)](https://travis-ci.org/mayakwd/tick-knock) [![Codecov Coverage](https://img.shields.io/codecov/c/github/mayakwd/tick-knock/develop.svg?style=flat-square)](https://codecov.io/gh/mayakwd/tick-knock/) 😊 [Buy me a coffee](https://www.buymeacoffee.com/rdolivaw) # Table of contents - [Installing] - [How it works?] - [Inside the Tick-Knock] - [Engine] - [Subscription] - [Component] - [Linked Component] - [Tag] - [Entity] - [System] - [Query] - [QueryBuilder] - [Queries and Systems] - [Built-in query-based systems] - [ReactionSystem] - [IterativeSystem] - [Snapshot] - [Shared Config] - [Linked Components How-To] - [Restrictions] - [Shared and Local Queries] - [Queries with complex logic and Entity invalidation] - [License] - [Donation] # Installing - Yarn: `yarn add tick-knock` - NPM: `npm i --save tick-knock` # How it works? Tick-Knock was inspired by several ECS libraries, mostly by [Ash ECS](https://www.richardlord.net/ash/). The main approach was re-imagined to make it lightweight, easy-to-use, and less boiler-plate based. # Inside the Tick-Knock In this part, you will learn all basics of Tick-Knock step by step. ## Engine Engine is a "world" where entities, systems, and queries interact with each other. Since the Engine is the initial entry point for development with Tick-Knock, it is from this point that the creation of your world starts. Usually, the Engine exists in just one instance, and it does nothing but orchestrating everything added to it. To begin with, you can add the most usual "inhabitants" to it. ```typescript const engine = new Engine(); const entity = new Entity() .add(new Hero()) .add(new health(10)) engine.addEntity(entity); ``` Or you can take it out: ```typescript engine.removeEntity(entity); ``` The second main "inhabitant" is System. It is responsible for processing Entities and their components. We will learn about them in detail later. ```typescript engine.addSystem(new ViewSystem(), 1); engine.addSystem(new PhysicsSystem(), 2); ``` As you may have noticed, we pass two parameters: system instance, and the second is update priority. The higher the priority number is, the later the system will be processed. The third type of resident is Query, which is responsible for mapping entities within the Engine and returns a list of already filtered and ready-to-use entities. ```typescript const heroesQuery = new Query((entity) => entity.has(Hero)); engine.addQuery(heroesQuery); ```` The main task of the engine is to start the world update process and to report on the ongoing changes to Queries. These changes can be: additions to and removal of entities from the Engine, and changes in the components of specific Entities. To perform the update step, we must call the `update` method and pass as a parameter the time elapsed since the previous update. Every time we start an update, the systems take turns, in order of priority, executing their own update methods. ```typescript // Half a second has passed from the previous step. engine.update(0.5); ``` ### Subscription An additional - one of the Engine's responsibilities - transferring the messages from systems to the user. This can be very useful when, for example, you want to report that the round in your game is over. ```typescript engine.subscribe(GameOver, (message: GameOver) => { if (game.win) { this.showWinMessage(); } else { this.showLoseMessage(); } }); ``` You can use not only class type as an argument but any value. For example, it could be a string or number. ```typescript const GAME_OVER = 'gameOver'; engine.subscribe(GAME_OVER, () => { this.showGameOver(); }); ``` > **Details of implementation** > > When the `dispatch` method is called in the system, then to get the right listeners, the compliance of > the `messageType` for each subscription will be checked. > - If `typeof subscription.messageType` is a `'function'`, then the matching will be performed using `instanceOf`. > - Otherwise, the matching will be done through strict equality `message === subscription.messageType`. ## Component It is a data object, its purpose - to represent a single aspect of your entity. For example, position, velocity, acceleration. - ❕ Any class could be considered as the component. There are no restrictions. - ❗ For proper understanding, it needs to be noticed that the component should be a data class, without any logic. Otherwise, you'll lose the benefits of the ECS pattern. **Let's write your first component:** ```typescript class Position { public constructor( public x: number = 0, public y: number = 0 ) {} } ``` > Yes, this is a component! 🎉 ## Linked component It is still a data class, but it is made to solve the problem when you need to have multiple components of the same type. Let's assume that you have a Damage component in your game. Several enemies attack the Hero simultaneously by adding the Damage component to it. What will happen? Only the last Damage component will be added to the Hero Entity because every previous one will be removed. To solve this problem - you need to implement ILinkedComponent interface in your Damage component and "append" instead of "add" the Damage component to the entity. That will do the job. After that, in DamageSystem you can find all damage sources: ```typescript class Damage extends LinkedComponent { public constructor( public readonly value: number ) { super() } } hero.append(new Damage(100)); hero.append(new Damage(5)); class DamageSystem extends IterativeSystem { public constructor() { super((entity) => entity.hasAll(Damage, Health)); } public updateEntity(entity: Entity) { const health = entity.get(Health)!; while (entity.has(Damage)) { const damage = entity.withdraw(Damage); health.value -= damage.value; } } } ``` ## Tag It also can be called a "label". It's a simplistic way to help you not "inflate" your code with classes without data. For instance, you want to mark your entity as Dead. There are two ways: - To create a component class: `class Dead {}` - Or to create a tag - that can be represented as a `string` or `number`. Using tags is much easier and consumes less memory if you do not have additional component data. **Example:** ```typescript const ENEMY = 'enemy'; const HERO = 100500; ``` > Keep it simple! 😄 ## Entity It is a general-purpose object, which can be marked with tags and can contain different components. - So it can be considered as a container that can represent any in-game entity, like an enemy, bomb, configuration, game state, etc. - Entity can contain only one component or tag of each type. You can't add two `Position` components to the entity, the second one will replace the first one. **This is how it works:** ```typescript const entity = new Entity() .add(new Position(100, 100)) .add(new Position(200, 200)) .add(HERO); console.log(entity.get(Position)); // Position(x = 200, y = 200) ``` > Looks easy? Yes, it is! ## System Systems are logic bricks in your application. If you want to manipulate entities, their components, and tags - it is the right place. Please, keep in mind that the complexity of the system mustn't be too high. When you find that your system is doing too much in the "update" method, you need to split it into several systems. Responsibility of the system should cover no more than one logical aspect. The system always has the following functionality: - Priority, which can be set before adding a system to the engine. - Reference to the `engine` will give you access to the engine itself and its entities. But be aware - you can't access an engine if the system is not connected to it. Otherwise, you'll get an error. - Methods `onAddedToEngine` and `onRemovedFromEngine` will be called in the cases described by their naming. - With the method `dispatch`, you can easily send a message outside of the system. It will be delivered through the engine [Subscription](#subscription) pipe. There are the same restrictions as for the engine. If the system is not attached to the engine, then an attempt to send a message will throw an error. - And last but not least, the heart of your system - method `update`. It will be called whenever `Engine.update` is being invoked. Update method - the right place to put your logic. **Example:** It's time to write our first and straightforward system. It will iterate through all the entities that are in the Engine, check if they have Position and Velocity components. And if they do, then move our object. ```typescript class Velocity { public constructor( public x: number = 0, public y: number = 0 ) {} } class PhysicsSystem extends System { public constructor() { super(); } public update(dt: number): void { const {entities} = this.engine; for (const entity of entities) { if (entity.hasAll(Position, Velocity)) { const position = entity.get(Position)!; const velocity = entity.get(Velocity)!; position.x += velocity.x * dt; position.y += velocity.y * dt; } } } } ``` > There you go! > 🎁 In real life, you don't have to iterate through every entity in every system. It's completely uncomfortable and not > optimal. In this library, there is a mechanism that can prepare a list of the entities that you need according to the > criteria you set - it's called Query. ## Query So what the "Query" is? It's a matching mechanism that can tell you which entities in the Engine are suitable for your needs. For example, you want to write a system that is responsible for displaying sprites on your screen. To do this, you always need a current list of entities, each of which has three components - View, Position, Rotation, and you want to exclude those marked with the HIDDEN tag. **Let's write our first Query.** ```typescript const displayListQuery = new Query((entity: Entity) => { return entity.hasAll(View, Position, Rotation) && !entity.has(HIDDEN); }); ``` > That's all! Adding this Query to the Engine will always contain an up-to-date list of entities that meet the described requirements. Besides, you can always find out when a new entity has appeared in the Query, or an old entity has left it. ```typescript displayListQuery.onEntityAdded.connect(({current}: EntitySnapshot) => { console.log("We've got a rookie here!"); container.addChild(current.get(View)!.view); }); displayListQuery.onEntityRemoved.connect(({previous}: EntitySnapshot) => { container.removeChild(previous.get(View)!.view); console.log("Good bye, friend!"); }); ``` ### QueryBuilder Query builder is super simple. It has not much power, but you can use it for creating queries that must contain specific Components. ```typescript const query: Query = new QueryBuilder() .contains(ComponentA, ComponentB) .contains(TAG) .build(); ``` ### Queries and Systems Now let's see how we can use Query on systems? Let's write `ViewSystem`, which will be responsible for displaying our Entity on the screen. When entities get to the list, the system will add them to the screen, and when they leave the list, the system will remove them from the screen. **Example:** ```typescript const query = new Query((entity: Entity) => { return entity.hasAll(View, Position, Rotation) && !entity.has(HIDDEN); }); class ViewSystem extends System { public constructor( private readonly container: Container ) { super(); } public onAddedToEngine(): void { // To make query work - we need to add it to the engine this.engine.addQuery(query); // And we need to add to the display list all entities that already // exists in the Engine`s world and matches our Query this.prepare(); // We want to know if new entities were added or removed query.onEntityAdded.connect(this.onEntityAdded); query.onEntityRemoved.connect(this.onEntityRemoved); } public onRemovedFromEngine(): void { // There is no reason to update query after system was removed // from the engine this.engine.removeQuery(query); // No reason for further listening of the updates query.onEntityAdded.disconnect(this.onEntityAdded); query.onEntityRemoved.disconnect(this.onEntityRemoved); } // We only want to update positions of the views on the screen, // so there is no need for "dt" parameter, it can be omitted public update(): void { const entities = this.query.entities; for (const entity of entities) { this.updatePosition(entity); } } private prepare(): void { for (const entity of this.query.entities) { this.onEntityAdded(entity); } } private updatePosition(entity: Entity): void { const {view} = entity.get(View)!; const {x, y} = entity.get(Position)!; const {rotation} = entity.get(Rotation)!; view.position.set(x, y); view.rotaion.set(rotation); } private onEntityAdded = ({current}: EntitySnapshot) => { // Let's add new view to the screen this.container.addChild(current.get(View)!.view); // Don't forget to update it's position on the screen this.updatePosition(current); }; private onEntityRemoved = ({previous}: EntitySnapshot) => { // Let's remove the view from the screen, because Entity no longer // meets the requirements (might be it lost the View component // or it was hidden) this.container.removeChild(previous.get(View)!.view); }; } ``` > 😎 I'm sure you saw the reference to `EntitySnapshot` and wondering, "what the heck is that?". Please, be > patient, [I'll tell you about](#Snapshot) it a bit later. > I think it looks good and clear for understanding! - 🤔 You can say: "we need to write too much boilerplate-code". - And of course, Tick-Knock will help you to reduce boilerplate-code! ### Built-in query-based systems In favor of reducing the time to write the boilerplate code - Tick-Knock provides two built-in systems. Each of them already knows how to work with Query, process the information coming from it, and allow access to this Query's entities. All of the following built-in systems have the following features: You can initialize those systems via three different items, which will be converted to Query eventually: - Query itself - Query predicate - Query will be automatically created on top of it. This feature was introduced to reduce the size of the boilerplate code. - QueryBuilder - it is also a valid option. - They have a getter `entities`, which returns the current entities list of the Query. - They have a built-in property entityAdded and entityRemoved, you need to define them if you want to track Query changes. #### ReactionSystem ReactionSystem can be considered as the system that has the ability to react to changes in Query. It is a basic built-in system. Exactly it will be used in most cases when developing your application. Let's try to rewrite our ViewSystem, taking ReactionSystem as a basis, and take advantage of all the conveniences it provides. **Example:** ```typescript class ViewSystem extends ReactionSystem { public constructor(private readonly container: Container) { super((entity: Entity) => { return entity.hasAll(View, Position, Rotation) && !entity.has(HIDDEN); }); } public update(): void { for (const entity of this.entities) { this.updatePosition(entity); } } protected prepare(): void { for (const entity of this.entities) { this.entityAdded(entity); } } private updatePosition(entity: Entity): void { const {view} = entity.get(View)!; const {x, y} = entity.get(Position)!; const {rotation} = entity.get(Rotation)!; view.position.set(x, y); view.rotaion.set(rotation); } protected entityAdded = ({current}: EntitySnapshot) => { this.updatePosition(current); this.container.addChild(current.get(View)!.view); }; protected entityRemoved = ({previous}: EntitySnapshot) => { this.container.removeChild(previous.get(View)!.view); }; } ``` > Now it's pretty simpler! 🎉 #### IterativeSystem This system has the same advantages as the ReactionSystem because it is inherited from the last one. 😅 All it brings is a built-in iteration cycle for our Query inside the update method. **So, let's upgrade our `ViewSystem` a bit.** ```typescript class ViewSystem extends IterativeSystem { // almost everything remains the same, so I'll skip most of the code. // The only difference regarding example with ReactionSystem - that we // don't need to override `update` method. // Instead of it we need to override updateEntity method. // Also, we can safely omit the dt parameter because we do not use it. protected updateEntity(entity: Entity, dt: number) { this.updatePosition(entity); } } ``` #### Remove the system as it's done It's possible to request removal of the system when you don't need it anymore. For example, the system is only needed to render the playing field, and trying to run it at every update cycle is wasteful. Fortunately, you can request deletion right from the system: ```typescript class RenderBoardSystem extends System { public update(dt: number): void { // Your render board code this.requestRemoval(); } } ``` That's it. Your system will be removed right after update cycle. ## Snapshot As you may have noticed, when we are tracking changes in Query, we get in `entityAdded` and `entityRemoved` not `Entity` but `EntitySnapshot`. **So what is a snapshot?** It is a container that displays the difference between the current state of Entity and its previous state. The `entity` property always reflects the current state. Still, methods ` get` and `has` methods of the snapshot return the data from the previous state of the Entity before it was changed. So you can understand which components have been added and which have been removed. > ❗ It is important to note that changes in the same entity components' data will not be reflected in the snapshot, even > if a manual invalidation of the entity has been triggered. Snapshots are very handy when you need to get a component or tag in Entity, but now it is missing. Let's take a closer look at it with our `ViewSystem` example. **Example:** ```typescript class ViewSystem extends IterativeSystem { // ... protected entityAdded = ({current}: EntitySnapshot) => { // When entity added to the Query that means that it has `View` // component - one hundred percent! So we just need its current // state. this.container.addChild(current.get(View)!.view); this.updatePosition(current); }; protected entityRemoved = ({previous}: EntitySnapshot) => { // But when entity removed - we can't be sure that current state // of the entity has `View` component. So we need to get it from // the previous state. Previous state has it one hundred percent. this.container.removeChild(previous.get(View)!.view); }; // ... } ``` ## Shared Config In real life, there is often a need to have a single Entity that acts as a configuration for the whole world. For example, you have a set of complex systems that involve both game logic and visualization, and animations. But for functional test purposes - you don't care about the visuals and animations. You face the situation of passing a specific flag in each system during initialization, which will be responsible for disabling animation and visualization. Now imagine that you have several configuration parameters, and each of them you need to pass to all systems of your world. To simplify handling such situations - you can use `Engine.sharedConfig`. Shared Config is an `Entity` available in all systems after adding them to `Engine`. **Example:** ```typescript const NO_VISUALS = 'no-visuals'; class ViewSystem extends IterativeSystem { protected updateEntity(entity: Entity): void { if (this.sharedConfig.has(NO_VISUALS)) { return; } // Otherwise - update visuals } } const engine = new Engine(); engine.sharedConfig.add(NO_VISUALS); engine.addSystem(new ViewSystem()); ``` > ☝ Shared Config is the single instance connected to `Engine` since its initialization and can't be removed from it. It > affects queries like any regular `Entity`. ## How to work with linked components? Tick-knock provides an extended API for working with linked components since version 4.0.0. - Method `withdraw` removes the first LinkedComponent component of the provided type or existing standard component - Method `pick` removes provided LinkedComponent component instance or existing standard component. **Example** You have a system responsible for checking boons (buffs) expiration, and you wish to remove expired boons from the hero: ```ts enum BoonType { PROTECTION, AEGIS, REGENERATION } class Boon extends LinkedComponent { public constructor( public readonly type: BoonType, public value: number, public duration: number ) { super(); } } class BoonExpirationTestSystem extends IterativeSystem { public constructor() { super((entity) => entity.has(Boon)); } public updateEntity(entity: Entity, dt: number) { // Let's update all boons entity.iterate(Boon, (boon) => { // Let's reduce boon remaining duration boon.duration -= dt; // If boon is expired if (boon.duration <= 0) { // Then we need to removed it from the Entity // But `entity.remove` will remove all boons, so we need to cherry-pick entity.pick(boon); } }); } } ``` - Method `iterate` iterates over instances of LinkedComponent and performs the `action` over each. Works for standard components (action will be called for a single instance in this case). > 🎈 It's safe to `pick` only current entity during iteration. - Method `find` searches a component instance of the specified class. Works for standard components (predicate will be called for a single instance in this case). - Method `getAll` returns a generator that can be used for iteration over all instances of specific type components. - Method `lengthOf` returns the number of existing components of the specified class. Now you know the basics. Now let's look at some examples to help you understand when linked components are helpful and how to work with them. ### Real world example We want to get a system that handles "Regeneration" buff on the hero. There can be more than one sources of regeneration, so we must handle all of them at the same time. Regeneration has two effects: - Instantly healing heroes by constant amount of health points - Regenerates some amount of health over the time. Thus, our system should do the following: - Heal the hero on the adding every new Regeneration buff. - Heal the hero over the time. - Manages regeneration expiration. ```ts class Regeneration extends LinkedComponent { public constructor( public instantHealValue: number, public healPerSecond: number, public duration: number ) { super(); } } class RegenerationSystem extends IterativeSystem { public constructor() { super((entity) => entity.has(Hero, Regeneration)); } public updateEntity(entity: Entity, dt: number) { const hero = entity.get(Hero)! // Let's update all regeneration components on our hero and apply their effects entity.iterate(Regeneration, (it) => { // We need to heal hero const healthPointsToAdd = Math.ceil(it.healPerSecond * dt); hero.health += healthPointsToAdd; // And then reduce regeneration duration it.duration -= dt; // If it's expired if (it.duration <= 0) { // Then we need to removed it from the Entity // But `entity.remove` will remove all boons, so we need to cherry-pick entity.pick(it); } }); } protected entityAdded = ({current}: EntitySnapshot) => { // When new entity appears in the queue, that means that it has Hero and Regeneration // so we want to instantly heal the hero by existing Regeneration buffs current.iterate(Regeneration, (regeneration) => { this.instantlyHealHero(entity, regeneration); }) // Also, if any additional Regeneration buff will appear in the entity, we will handle // them as well and instantly heal the hero current.onComponentAdded.connect(this.instantlyHealHero); } protected entityRemoved = ({current}: EntitySnapshot) => { // We don't want to know if any new components were added to the entity when it left // the queue already. current.onComponentAdded.disconnect(this.instantlyHealHero); } private instantlyHealHero = (entity: Entity, regeneration: any) => { // We need to filter components, because this function will called on every added // component (not only Regeneration) if (!(regeneration instanceof Regeneration)) return; const hero = entity.get(Hero)!; hero.health += regeneration.instantHealValue; } } ``` # Restrictions ## Shared and Local Queries In real development, you'll definitely face a situation when you want to reuse Query. For example, when developing a game with heroes and enemies, you will surely always need two queries: **Simplified version** ```typescript const heroes = new Query(entity => entity.has(Hero)); const enemies = new Query(entity => entity.has(Enemy)); ``` And you will want to use them in different systems. But the systems use local Queries. This means that after excluding a system from Engine, the Query in it will no longer be updated. To prevent this from happening, you need to use the shared queries approach. To do this, you only need to add the query manually after initializing the Engine. > shared-queries.ts ```typescript export const heroes = new Query(entity => entity.has(Hero)); export const enemies = new Query(entity => entity.has(Enemy)); ``` ```typescript import {heroes, enemies} from 'shared-queries'; // ... engine.addQuery(heroes); engine.addQuery(enemies) ``` Now you can use these Queries in any other system. **Example:** ```typescript import {heroes, enemies} from 'shared-queries'; class DamageSystem extends IterativeSystem { // ... protected updateEntity(entity: Entity) { const damage = entity.remove(Damage)! const isHero = heroes.has(entity); if (damage.type === DamageType.SPLASH) { const neighbours = getNeighbours(isHero ? heroes : enemies); // ... } } } ``` ## Queries with complex logic and Entity invalidation There are limitations for Query that do not allow you to track changes made inside components automatically. Suppose that you want Query to track entities with an X position of 10. ```typescript const query = new Query((entity) => entity.has(Position) && entity.get(Position).x === 10); ``` And you have changed the Position parameters accordingly: ```typescript entity.get(Position)!.x = 10; ``` The query will not know about these changes because the mechanism for tracking changes in component fields is redundant and heavy, which will have a huge impact on performance. But to fix this, you can use an entity method called `invalidate`, it will force Query to check this particular entity. ❗ Try not to use this approach too often. It may affect the performance of your application. # License This software released under [MIT](https://github.com/Leopotam/ecs/blob/master/LICENSE.md) license! Good luck, folks. [Restrictions]: #restrictions [Shared Config]: #shared-config [Shared and Local Queries]: #shared-and-local-queries [Queries with complex logic and Entity invalidation]: #queries-with-complex-logic-and-entity-invalidation [Snapshot]: #snapshot [IterativeSystem]: #iterativesystem [ReactionSystem]: #reactionsystem [Built-in query-based systems]: #built-in-query-based-systems [Queries and Systems]: #queries-and-systems [QueryBuilder]: #querybuilder [Query]: #query [System]: #system [Entity]: #entity [Tag]: #tag [Component]: #component [Linked Component]: #linked-component [Linked Components How-To]: #how-to-work-with-linked-components [Installing]: #installing [How it works?]: #how-it-works [Inside the Tick-Knock]: #inside-the-tick-knock [Subscription]: #subscription [Engine]: #engine [License]: #license ================================================ FILE: jest-ci.json ================================================ { "transform": { "^.+\\.tsx?$": "ts-jest" }, "collectCoverage": true, "moduleFileExtensions": [ "ts", "js" ], "testMatch": [ "**/tests/unit/**/*.spec.(js|ts)|**/__tests__/*.(js|ts)" ], "transformIgnorePatterns": [ "/node_modules/" ], "moduleNameMapper": { "^@/(.*)$": "/src/$1" } } ================================================ FILE: package.json ================================================ { "name": "tick-knock", "version": "4.3.0", "description": "TypeScript Entity-Component-System library", "author": "Ilya Malanin", "license": "MIT", "main": "lib/index.js", "typings": "lib/index.d.ts", "scripts": { "setup": "yarn install", "build": "tsc", "build-watch": "tsc --watch", "test": "jest", "test-ci": "jest --config jest-ci.json" }, "devDependencies": { "@types/jest": "^29.2.4", "@types/node": "^18.11.17", "jest": "^29.3.1", "ts-jest": "^29.0.3", "ts-node": "^10.9.1", "typescript": "^4.9.5" }, "dependencies": { "tslib": "^2.4.0" }, "files": [ "lib" ], "repository": { "type": "git", "url": "git+https://github.com/mayakwd/tick-knock.git" }, "bugs": { "url": "https://github.com/mayakwd/tick-knock/issues" }, "homepage": "https://github.com/mayakwd/tick-knock#readme", "keywords": [ "ecs", "entity", "typescript", "entity-component-system", "gamedev", "game" ], "jest": { "transform": { "^.+\\.tsx?$": "ts-jest" }, "moduleFileExtensions": [ "ts", "js" ], "testMatch": [ "**/tests/unit/**/*.spec.(js|ts)|**/__tests__/*.(js|ts)" ], "transformIgnorePatterns": [ "/node_modules/" ], "moduleNameMapper": { "^@/(.*)$": "/src/$1" } } } ================================================ FILE: src/ecs/ComponentId.ts ================================================ import {Class} from '../utils/Class'; /** * Gets an id for a component class. * * @param component Component class * @param createIfNotExists - If `true` then unique id for class component will be created, * in case if it wasn't assigned earlier */ export function getComponentId( component: Class, createIfNotExists: boolean = false, ): number | undefined { if (component.hasOwnProperty(COMPONENT_CLASS_ID)) { return (component as ComponentId)[COMPONENT_CLASS_ID]; } else if (createIfNotExists) { return (component as ComponentId)[COMPONENT_CLASS_ID] = componentClassId++; } return undefined; } /** * @internal */ export function getComponentClass(component: NonNullable, resolveClass?: Class) { let componentClass = Object.getPrototypeOf(component).constructor as Class; if (resolveClass) { if (!(component instanceof resolveClass || componentClass === resolveClass)) { throw new Error('Resolve class should be an ancestor of component class'); } componentClass = resolveClass as Class; } return componentClass; } let COMPONENT_CLASS_ID = '__componentClassId__'; let componentClassId: number = 1; type ComponentId = Class & { [key: string]: number; }; ================================================ FILE: src/ecs/Engine.ts ================================================ import {Entity} from './Entity'; import {System} from './System'; import {Class} from '../utils/Class'; import {Query} from './Query'; import {Subscription} from './Subscription'; import {Signal} from '../utils/Signal'; /** * Engine represents game state, and provides entities update loop on top of systems. */ export class Engine { /** * Signal dispatches when new entity were added to engine */ public onEntityAdded: Signal<(entity: Entity) => void> = new Signal(); /** * Signal dispatches when entity was removed from engine */ public onEntityRemoved: Signal<(entity: Entity) => void> = new Signal(); private _entityMap: Map = new Map(); private _entities: Entity[] = []; private _systems: System[] = []; private _queries: Query[] = []; private _subscriptions: Subscription[] = []; private _sharedConfig: Entity = new Entity(); private _removalRequested: Set = new Set(); /** * Gets a list of entities added to engine */ public get entities(): ReadonlyArray { return Array.from(this._entities); } /** * Gets a list of systems added to engine */ public get systems(): ReadonlyArray { return this._systems; } /** * Gets a list of queries added to engine */ public get queries(): ReadonlyArray { return this._queries; } public constructor() { this.connectEntity(this._sharedConfig); } /** * @internal */ public get subscriptions(): ReadonlyArray> { return this._subscriptions; } /** * Gets a shared config entity, that's accessible from every system added to engine * * @return {Entity} */ public get sharedConfig(): Entity { return this._sharedConfig; } /** * Adds an entity to engine. * If entity is already added to engine - it does nothing. * * @param entity Entity to add to engine * @see onEntityAdded */ public addEntity(entity: Entity): Engine { if (this._entityMap.has(entity.id)) { this._removalRequested.delete(entity.id); return this; } this._entities.push(entity); this._entityMap.set(entity.id, entity); this.onEntityAdded.emit(entity); this.connectEntity(entity); return this; } /** * Remove entity from engine * If engine not contains entity - it does nothing. * * @param entity Entity to remove from engine * @param safe If true - entity will be removed after update loop, if false - entity is removed immediately. * @since 4.3.0 - Added `safe` option. * The "safe" flag will be removed in the next major version release, and the default behavior will be changed to "safe". * @see onEntityRemoved */ public removeEntity(entity: Entity, safe: boolean = false): Engine { if (!this._entityMap.has(entity.id)) return this; if (!safe) { return this.removeEntityNow(entity); } this._removalRequested.add(entity.id) return this; } /** * Gets an entity by its id * * @param {number} id Entity identifier * @return {Entity | undefined} corresponding entity or undefined if it's not found. */ public getEntityById(id: number): Entity | undefined { if (this._removalRequested.has(id)) return undefined; return this._entityMap.get(id); } /** * Removes a system from engine * Avoid remove the system during update cycle, do it only if your sure what you are doing. * Note: {@link IterativeSystem} has aware guard during update loop, if system removed - updating is being stopped. * * @param system System to remove */ public removeSystem(system: System): Engine { const index = this._systems.indexOf(system); if (index === -1) return this; this._systems.splice(index, 1); system.onRemovedFromEngine(); system.setEngine(undefined); return this; } /** * Updates the engine. This cause updating all the systems in the engine in the order of priority they've been added. * * @param dt Delta time in seconds */ public update(dt: number): void { for (const system of this._systems) { system.update(dt); if (system.isRemovalRequested) { this.removeSystem(system); } } if (this._removalRequested.size > 0) { for (const id of this._removalRequested) { const entity = this._entityMap.get(id); if (entity) { this.removeEntityNow(entity); } } this._removalRequested.clear(); } } /** * Gets a system of the specific class * * @param systemClass Class of the system that should be found */ public getSystem(systemClass: Class): T | undefined { return this._systems.find(value => value instanceof systemClass) as T; } /** * Remove all systems */ public removeAllSystems(): void { const systems = this._systems; this._systems = []; for (const system of systems) { system.onRemovedFromEngine(); } } /** * Remove all queries. * After remove all queries will be cleared. */ public removeAllQueries(): void { const queries = this._queries; this._queries = []; for (const query of queries) { this.disconnectQuery(query); query.clear(); } } /** * Remove all entities. * onEntityRemoved will be fired for every entity. */ public removeAllEntities(): void { this.removeAllEntitiesInternal(false); } /** * Removes all entities, queries and systems. * All entities will be removed silently, {@link onEntityRemoved} event will not be fired. * Queries will be cleared. */ public clear(): void { this.removeAllEntitiesInternal(true); this.removeAllSystems(); this.removeAllQueries(); } private removeEntityNow(entity: Entity): Engine { const index = this._entities.indexOf(entity); this._entities.splice(index, 1); this._entityMap.delete(entity.id); this.onEntityRemoved.emit(entity); this.disconnectEntity(entity); return this; } /** * Adds a query to engine. It matches all available in engine entities with query. * * When any entity will be added, removed, their components will be modified - this query will be updated, * until not being removed from engine. * * @param query Entity match query */ public addQuery(query: Query): Engine { this.connectQuery(query); query.matchEntities(this.entities); this._queries[this._queries.length] = query; return this; } /** * Adds a system to engine, and set its priority inside of engine update loop. * * @param system System to add to the engine * @param priority Value indicating the priority of updating system in update loop. Lower priority * means sooner update. */ public addSystem(system: System, priority: number = 0): Engine { system.setPriority(priority); if (this._systems.length === 0) { this._systems[0] = system; } else { const index = this._systems.findIndex(value => value.priority > priority); if (index === -1) { this._systems[this._systems.length] = system; } else { this._systems.splice(index, 0, system); } } system.setEngine(this); system.onAddedToEngine(); return this; } /** * Removes a query and clear it. * * @param query Entity match query */ public removeQuery(query: Query) { const index = this._queries.indexOf(query); if (index == -1) return undefined; this._queries.splice(index, 1); this.disconnectQuery(query); query.clear(); return this; } /** * Subscribe to any message of the {@link messageType}. * Those messages can be dispatched from any system attached to the engine * * @param {Class | T} messageType - Message type (can be class or any instance, for example string or number) * @param {(value: T) => void} handler - Handler for the message */ public subscribe(messageType: Class | T, handler: (value: T) => void): Subscription { return this.addSubscription(messageType, handler); } /** * Unsubscribe from messages of specific type * * @param {Class} messageType - Message type * @param {(value: T) => void} handler - Specific handler that must be unsubscribed, if not defined then all handlers * related to this message type will be unsubscribed. */ public unsubscribe(messageType: Class | T, handler?: (value: T) => void): void { this.removeSubscription(messageType, handler); } /** * Unsubscribe from all type of messages */ public unsubscribeAll(): void { this._subscriptions.length = 0; } /** * @internal */ public addSubscription(messageType: Class | T, handler: (value: T) => void): Subscription { for (const subscription of this._subscriptions) { if (subscription.equals(messageType, handler)) return subscription; } const subscription = new Subscription(messageType, handler); this._subscriptions.push(subscription); return subscription; } /** * @internal */ public removeSubscription(messageType: Class | T, handler: ((value: T) => void) | undefined): void { let i = this._subscriptions.length; while (--i >= 0) { const subscription = this._subscriptions[i]; if (subscription.equals(messageType, handler)) { this._subscriptions.splice(i, 1); if (handler !== undefined) return; } } } /** * @internal */ public dispatch(message: T) { for (const subscription of this._subscriptions) { if ((typeof subscription.messageType === 'function' && message instanceof subscription.messageType) || message === subscription.messageType) { subscription.handler(message); } } } private connectEntity(entity: Entity) { entity.onComponentAdded.connect(this.onComponentAdded, Number.POSITIVE_INFINITY); entity.onComponentRemoved.connect(this.onComponentRemoved, Number.POSITIVE_INFINITY); entity.onInvalidationRequested.connect(this.onInvalidationRequested, Number.NEGATIVE_INFINITY); } private disconnectEntity(entity: Entity) { entity.onComponentAdded.disconnect(this.onComponentAdded); entity.onComponentRemoved.disconnect(this.onComponentRemoved); entity.onInvalidationRequested.disconnect(this.onInvalidationRequested); } private connectQuery(query: Query) { this.onEntityAdded.connect(query.entityAdded); this.onEntityRemoved.connect(query.entityRemoved); } private disconnectQuery(query: Query) { this.onEntityAdded.disconnect(query.entityAdded); this.onEntityRemoved.disconnect(query.entityRemoved); } private removeAllEntitiesInternal(silently: boolean): void { const entities = this._entities; this._entities = []; this._entityMap.clear(); for (const entity of entities) { if (!silently) { this.onEntityRemoved.emit(entity); } this.disconnectEntity(entity); } } private onComponentAdded = (entity: Entity, component: NonNullable, componentClass?: Class>) => { this._queries.forEach(value => value.entityComponentAdded(entity, component, componentClass)); }; private onInvalidationRequested = (entity: Entity) => { this._queries.forEach(value => value.validateEntity(entity)); }; private onComponentRemoved = (entity: Entity, component: NonNullable, componentClass?: Class>) => { this._queries.forEach(value => value.entityComponentRemoved(entity, component, componentClass)); }; } ================================================ FILE: src/ecs/Entity.ts ================================================ import {getComponentClass, getComponentId} from './ComponentId'; import {Class} from '../utils/Class'; import {Signal} from '../utils/Signal'; import {isTag, Tag} from './Tag'; import {ILinkedComponent, isLinkedComponent} from './LinkedComponent'; import {LinkedComponentList} from './LinkedComponentList'; /** * Entity readonly interface */ export interface ReadonlyEntity { /** * The signal dispatches if new component or tag was added to the entity */ readonly onComponentAdded: Signal; /** * The signal dispatches if component was removed from the entity */ readonly onComponentRemoved: Signal; /** * Returns components map, where key is component identifier, and value is a component itself * @see {@link getComponentId}, {@link Entity.getComponents} */ readonly components: Readonly>; /** * Returns set of tags applied to the entity * @see getComponentId */ readonly tags: ReadonlySet; /** * Returns value indicating whether entity has a specific component or tag * * @param {Class | Tag} componentClassOrTag * @param id Identifier of the LinkedComponent * @example * ```ts * const BERSERK = 10091; * if (!entity.has(Immobile) || entity.has(BERSERK)) { * const position = entity.get(Position)!; * position.x += 1; * } * ``` */ has(componentClassOrTag: Class | Tag, id?: string): boolean; /** * Returns value indicating whether entity contains a component instance. * If the component is an instance of ILinkedComponent then all components of its type will be checked for equality. * * @param {T} component * @param {Class} resolveClass * @example * ```ts * const boon = new Boon(BoonType.HEAL); * entity * .append(new Boon(BoonType.PROTECTION)); * .append(boon); * * if (entity.contains(boon)) { * logger.info('Ah, sweet. We have not only protection but heal as well!'); * } * ``` */ contains(component: T, resolveClass?: Class): boolean; /** * Returns value indicating whether entity has a specific component * * @param component * @param id Identifier of the LinkedComponent * @example * ``` * if (!entity.hasComponent(Immobile)) { * const position = entity.get(Position)!; * position.x += 1; * } * ``` */ hasComponent(component: Class, id?: string): boolean; /** * Returns value indicating whether entity has a specific tag * * @param tag * @example * ```ts * const BERSERK = "berserk"; * let damage = initialDamage; * if (entity.hasTag(BERSERK)) { * damage *= 1.2; * } * ``` */ hasTag(tag: Tag): boolean; /** * Returns value indicating whether entity have any of specified components/tags * * @param {Class | Tag} componentClassOrTag * @returns {boolean} * @example * ```ts * const IMMORTAL = "immortal"; * if (!entity.hasAny(Destroy, Destroying, IMMORTAL)) { * entity.add(new Destroy()); * } * ``` */ hasAny(...componentClassOrTag: Array | Tag>): boolean; /** * Returns value indicating whether entity have all of specified components/tags * * @param {Class | Tag} componentClassOrTag * @returns {boolean} * @example * ```ts * const I_LOVE_GRAVITY = "no-i-don't"; * if (entity.hasAll(Position, Acceleration, I_LOVE_GRAVITY)) { * entity.get(Position)!.y += entity.get(Acceleration)!.y * dt; * } * ``` */ hasAll(...componentClassOrTag: Array | Tag>): boolean; /** * Returns an array of entity components * * @returns {unknown[]} */ getComponents(): unknown[]; /** * Returns an array of tags applied to the entity */ getTags(): Tag[]; /** * Gets a component instance if it's exists in the entity, otherwise returns `undefined` * - If you want to check presence of the tag then use {@link has} instead. * * @param componentClass Specific component class * @param id Identifier of the LinkedComponent */ get(componentClass: Class, id?: string): T | undefined; /** * Iterates over instances of linked component appended to the Entity and performs the action over each.
* Works and for standard components (action will be called for a single instance in this case). * * @param {Class} componentClass Component`s class * @param {(component: T) => void} action Action to perform over every component instance. * @example * ```ts * class Boon extends LinkedComponent { * public constructor( * public type: BoonType, * public duration: number * ) { super(); } * } * const entity = new Entity() * .append(new Boon(BoonType.HEAL, 2)) * .append(new Boon(BoonType.PROTECTION, 3); * * // Let's decrease every boon duration and remove them if they are expired. * entity.iterate(Boon, (boon) => { * if (--boon.duration <= 0) { * entity.pick(boon); * } * }); * ``` */ iterate(componentClass: Class, action: (component: T) => void): void; /** * Returns generator with all instances of specified linked component class * * @param {Class} componentClass Component`s class * @example * ```ts * for (const damage of entity.linkedComponents(Damage)) { * if (damage.value < 0) { * throw new Error('Damage value can't be less than zero'); * } * ``` */ getAll(componentClass: Class): Generator; /** * Searches a component instance of specified linked component class. * Works and for standard components (predicate will be called for a single instance in this case). * * @param {Class} componentClass * @param {(component: T) => boolean} predicate * @return {T | undefined} */ find(componentClass: Class, predicate: (component: T) => boolean): T | undefined; /** * Returns number of components of specified class. * * @param {Class} componentClass * @return {number} */ lengthOf(componentClass: Class): number; } /** * Entity is a general purpose object, which can be marked with tags and can contain different components. * So it is just a container, that can represent any in-game entity, like enemy, bomb, configuration, game state, etc. * * @example * ```ts * // Here we can see structure of the component "Position", it's just a data that can be attached to the Entity * // There is no limits for component`s structure. * // Components mustn't hold the reference to the entity that it attached to. * * class Position { * public x:number; * public y:number; * * public constructor(x:number = 0, y:number = 0) { * this.x = x; * this.y = y; * } * } * * // We can mark an entity with the tag OBSTACLE. Tag can be represented as a number or string. * const OBSTACLE = 10100; * * const entity = new Entity() * .add(OBSTACLE) * .add(new Position(10, 5)); * ``` */ export class Entity implements ReadonlyEntity { /** * The signal dispatches if new component or tag was added to the entity. Works for every linked component as well. */ public readonly onComponentAdded: Signal = new Signal(); /** * The signal dispatches if component was removed from the entity. Works for every linked component as well. */ public readonly onComponentRemoved: Signal = new Signal(); /** * The signal dispatches that invalidation requested for this entity. * Which means that if the entity attached to the engine — its queries will be updated. * * Use {@link Entity.invalidate} method in case if in query test function is using component properties or complex * logic. * * Only adding/removing components and tags are tracked by Engine. So you need to request queries invalidation * manually, if some of your queries depends on logic or component`s properties. */ public readonly onInvalidationRequested: Signal<(entity: Entity) => void> = new Signal(); /** * Unique id identifier */ public readonly id = entityId++; private _components: Record = {}; private _linkedComponents: Record> = {}; private _tags: Set = new Set(); /** * Returns components map, where key is component identifier, and value is a component itself * @see {@link getComponentId}, {@link Entity.getComponents} */ public get components(): Readonly> { return this._components; } /** * Returns set of tags applied to the entity * @see getComponentId */ public get tags(): ReadonlySet { return this._tags; } /** * Adds a component or tag to the entity. * It's a unified shorthand for {@link addComponent} and {@link addTag}. * * - If a component of the same type already exists in entity, it will be replaced by the passed one (only if * component itself is not the same, in this case - no actions will be done). * - If the tag is already present in the entity - no actions will be done. * - During components replacement {@link onComponentRemoved} and {@link onComponentAdded} are will be triggered * sequentially. * - If there is no component of the same type, or the tag is not present in the entity - then only * - If the passed component is an instance of ILinkedComponent then all existing instances will be removed, and the * passed instance will be added to the Entity. {@link onComponentRemoved} will be triggered for every removed * instance and {@link onComponentAdded} will be triggered for the passed component. * - Linked component always replaces all existing instances. Even if the passed instance already exists in the * Entity - all existing linked components will be removed anyway, and replaced with the passed one. * * @throws Throws error if component is null or undefined, or if component is not an instance of the class as well * @param {T | Tag} componentOrTag Component instance or Tag * @param {K} resolveClass Class that should be used as resolving class. * Passed class always should be an ancestor of Component's class. * It has sense only if component instance is passed, but not the Tag. * @returns {Entity} Reference to the entity itself. It helps to build chain of calls. * @see {@link addComponent, appendComponent}, {@link addTag} * @example * ```ts * const BULLET = 1; * const EXPLOSIVE = "explosive"; * const entity = new Entity() * .add(new Position()) * .add(new View()) * .add(new Velocity()) * .add(BULLET) * .add(EXPLOSIVE); * ``` */ public add(componentOrTag: NonNullable | Tag, resolveClass?: Class): Entity { if (isTag(componentOrTag)) { this.addTag(componentOrTag); } else { this.addComponent(componentOrTag, resolveClass); } return this; } /** * Appends a linked component to the entity. * * - If linked component is not exists, then it will be added to the Entity and {@link onComponentAdded} * will be triggered. * - If component already exists in the entity, then passed one will be appended to the tail. {@link onComponentAdded} * will be triggered as well. * * It's a shorthand to {@link appendComponent} * * @throws Throws error if component is null or undefined, or if component is not an instance of the class as well * @param {T | Tag} component ILinkedComponent instance * @param {K} resolveClass Class that should be used as resolving class. * Passed class always should be an ancestor of Component's class. * * @returns {Entity} Reference to the entity itself. It helps to build chain of calls. * @see {@link addComponent} * @see {@link appendComponent} * @example * ```ts * const damage = new Damage(); * const entity = new Entity() * .append(new Damage(1)) * .append(new Damage(2)) * * const damage = entity.get(Damage); * while (entity.has(Damage)) { * const entity = entity.withdraw(Damage); * print(damage.value); * } * ``` */ public append(component: NonNullable, resolveClass?: Class): Entity { return this.appendComponent(component, resolveClass); } /** * Removes first appended linked component instance of the specified type. * Unlike {@link remove} and {@link removeComponent} remaining linked components stays in the Entity. * * - If linked component exists in the Entity, then it will be removed from Entity and {@link onComponentRemoved} * will be triggered. * * @param {Class} componentClass * @return {T | undefined} Component instance if any of the specified type exists in the entity, otherwise undefined * @example * ```ts * const entity = new Entity() * .append(new Damage(1)) * .append(new Damage(2)) * .append(new Damage(3)); * * entity.withdraw(Damage); * entity.iterate(Damage, (damage) => { * print('Remaining damage: ' + damage.value); * }); * * // Remaining damage: 2 * // Remaining damage: 3 * ``` */ public withdraw(componentClass: Class): T | undefined { const component = this.get(componentClass); if (component === undefined) return; if (isLinkedComponent(component)) { return this.withdrawComponent(component, componentClass as Class); } else { return this.remove(componentClass); } } /** * Removes particular linked component instance from the Entity by its id. * * - If linked component instance exists in the Entity, then it will be removed from Entity and * {@link onComponentRemoved} will be triggered. * * @param {Class} resolveClass Resolve class * @param {string} id Linked component id * @return {T | undefined} Component instance if it exists in the entity, otherwise undefined */ public pick(resolveClass: Class, id: string): T | undefined; /** * Removes particular linked component instance from the Entity. * * - If linked component instance exists in the Entity, then it will be removed from Entity and * {@link onComponentRemoved} will be triggered. * * @param {NonNullable} component Linked component instance * @param {Class | undefined} resolveClass Resolve class * @return {T | undefined} Component instance if it exists in the entity, otherwise undefined */ public pick(component: NonNullable, resolveClass?: Class): T | undefined; public pick(componentOrResolveClass: NonNullable | Class, resolveClassOrId?: Class | string): T | undefined { if (typeof resolveClassOrId === 'string') { const component = this.find(componentOrResolveClass as Class, (component) => isLinkedComponent(component) && component.id === resolveClassOrId); if (isLinkedComponent(component)) { return this.withdrawComponent(component, componentOrResolveClass as Class); } return undefined; } if (isLinkedComponent(componentOrResolveClass)) { return this.withdrawComponent(componentOrResolveClass, resolveClassOrId as Class); } return this.remove(resolveClassOrId ?? getComponentClass(componentOrResolveClass as NonNullable)); } /** * Adds a component to the entity. * * - If a component of the same type already exists in entity, it will be replaced by the passed one (only if * component itself is not the same, in this case - no actions will be done). * - During components replacement {@link onComponentRemoved} and {@link onComponentAdded} are will be triggered * sequentially. * - If there is no component of the same type - then only {@link onComponentAdded} will be triggered. * * @throws Throws error if component is null or undefined, or if component is not an instance of the class as well * @param {T} component Component instance * @param {K} resolveClass Class that should be used as resolving class. * Passed class always should be an ancestor of Component's class. * @returns {Entity} Reference to the entity itself. It helps to build chain of calls. * @see {@link add}, {@link addTag} * @example * ```ts * const BULLET = 1; * const entity = new Entity() * .addComponent(new Position()) * .addComponent(new View()) * .add(BULLET); * ``` */ public addComponent(component: NonNullable, resolveClass?: Class): Entity { const componentClass = getComponentClass(component, resolveClass); const id = getComponentId(componentClass, true)!; const linkedComponent = isLinkedComponent(component); if (this._components[id] !== undefined) { if (!linkedComponent && component === this._components[id]) { return this; } this.remove(componentClass); } if (linkedComponent) { this.append(component as ILinkedComponent, resolveClass as Class); } else { this._components[id] = component; this.dispatchOnComponentAdded(component); } return this; } /** * Appends a linked component to the entity. * * - If linked component is not exists, then it will be added via `addComponent` method and {@link onComponentAdded} * will be triggered. * - If component already exists in the entity, then passed one will be appended to the tail. {@link * onComponentAdded} won't be triggered. * * @throws Throws error if component is null or undefined, or if component is not an instance of the class as well * @param {T | Tag} component ILinkedComponent instance * @param {K} resolveClass Class that should be used as resolving class. * Passed class always should be an ancestor of Component's class. * * @returns {Entity} Reference to the entity itself. It helps to build chain of calls. * @see {@link append} * @see {@link addComponent} * @example * ```ts * const damage = new Damage(); * const entity = new Entity() * .append(new Damage()) * .append(new Damage()) * * const damage = entity.get(Damage); * while (damage !== undefined) { * print(damage.value); * damage = damage.next; * } * ``` */ public appendComponent(component: NonNullable, resolveClass?: Class): Entity { const componentClass = getComponentClass(component, resolveClass); const componentId = getComponentId(componentClass, true)!; const componentList = this.getLinkedComponentList(componentId)!; componentList.add(component); if (this._components[componentId] === undefined) { this._components[componentId] = componentList.head; } this.dispatchOnComponentAdded(component); return this; } /** * Adds a tag to the entity. * * - If the tag is already present in the entity - no actions will be done. * - If there is such tag in the entity then {@link onComponentAdded} will be triggered. * * @param {Tag} tag Tag * @returns {Entity} Reference to the entity itself. It helps to build chain of calls. * @see {@link add}, {@link addComponent} * @example * ```ts * const DEVELOPER = "developer; * const EXHAUSTED = 2; * const = "game-over"; * const entity = new Entity() * .addTag(DEVELOPER) * .add(EXHAUSTED) * ``` */ public addTag(tag: Tag): Entity { if (!this._tags.has(tag)) { this._tags.add(tag); this.dispatchOnComponentAdded(tag); } return this; } /** * Returns componentClassOrTag indicating whether entity has a specific component or tag * * @param componentClassOrTag * @param id Identifier of the LinkedComponent * @example * ```ts * const BERSERK = 10091; * if (!entity.has(Immobile) || entity.has(BERSERK)) { * const position = entity.get(Position)!; * position.x += 1; * } * ``` */ public has(componentClassOrTag: Class | Tag, id?: string): boolean { if (isTag(componentClassOrTag)) { return this.hasTag(componentClassOrTag); } return this.hasComponent(componentClassOrTag, id); } /** * Returns value indicating whether entity contains a component instance. * If the component is an instance of ILinkedComponent then all components of its type will be checked for equality. * * @param {NonNullable} component * @param {Class} resolveClass * @example * ```ts * const boon = new Boon(BoonType.HEAL); * entity * .append(new Boon(BoonType.PROTECTION)); * .append(boon); * * if (entity.contains(boon)) { * logger.info('Ah, sweet. We have not only protection but heal as well!'); * } * ``` */ public contains(component: NonNullable, resolveClass?: Class): boolean { const componentClass = getComponentClass(component, resolveClass); if (isLinkedComponent(component)) { return this.find(componentClass, (value) => value === component) !== undefined; } return this.get(componentClass) === component; } /** * Returns value indicating whether entity has a specific component * * @param component Component class * @param id Identifier of the LinkedComponent * * @example * ``` * if (!entity.hasComponent(Immobile)) { * const position = entity.get(Position)!; * position.x += 1; * } * ``` */ public hasComponent(component: Class, id?: string): boolean { return this.get(component, id) !== undefined; } /** * Returns value indicating whether entity has a specific tag * * @param tag * @example * ```ts * const BERSERK = "berserk"; * let damage = initialDamage; * if (entity.hasTag(BERSERK)) { * damage *= 1.2; * } * ``` */ public hasTag(tag: Tag): boolean { return this._tags.has(tag); } /** * Returns value indicating whether entity have any of specified components/tags * * @param {Class | Tag} componentClassOrTag * @returns {boolean} * @example * ```ts * const IMMORTAL = "immortal"; * if (!entity.hasAny(Destroy, Destroying, IMMORTAL)) { * entity.add(new Destroy()); * } * ``` */ public hasAny(...componentClassOrTag: Array | Tag>): boolean { return componentClassOrTag.some(value => this.has(value)); } /** * Returns value indicating whether entity have all of specified components/tags * * @param {Class | Tag} componentClassOrTag * @returns {boolean} * @example * ```ts * const I_LOVE_GRAVITY = "no-i-don't"; * if (entity.hasAll(Position, Acceleration, I_LOVE_GRAVITY)) { * entity.get(Position)!.y += entity.get(Acceleration)!.y * dt; * } * ``` */ public hasAll(...componentClassOrTag: Array | Tag>): boolean { return componentClassOrTag.every(value => this.has(value)); } /** * Gets a component instance if it's exists in the entity, otherwise returns `undefined` * - If you want to check presence of the tag then use {@link has} instead. * * @param componentClass Specific component class * @param id Identifier of the LinkedComponent */ public get(componentClass: Class, id?: string): T | undefined { const cid = getComponentId(componentClass); if (cid === undefined) return undefined; let component = this._components[cid]; if (id !== undefined) { if (isLinkedComponent(component)) { while (component !== undefined) { if ((component as ILinkedComponent).id === id) return component as T; component = (component as ILinkedComponent).next; } } return undefined; } return this._components[cid] as T; } /** * Returns an array of entity components * * @returns {unknown[]} */ public getComponents(): unknown[] { return Array.from(Object.values(this._components)); } /** * Returns an array of tags applied to the entity */ public getTags(): Tag[] { return Array.from(this._tags); } /** * Removes a component or tag from the entity. * In case if the component or tag is present - then {@link onComponentRemoved} will be * dispatched after removing it from the entity. * * If linked component type provided: * - For each instance of linked component {@link onComponentRemoved} will be called * - Only head of the linked list will be returned. * * If you need to get all instances use {@link withdraw} or {@link pick} instead, or check {@link iterate}, * {@link getAll} * * It's a shorthand for {@link removeComponent} * * @param componentClassOrTag Specific component class or tag * @returns Component instance or `undefined` if it doesn't exists in the entity, or tag was removed * @see {@link withdraw} * @see {@link pick} */ public remove(componentClassOrTag: Class | Tag): T | undefined { if (isTag(componentClassOrTag)) { this.removeTag(componentClassOrTag); return undefined; } return this.removeComponent(componentClassOrTag); } /** * Removes a component from the entity. * In case if the component or tag is present - then {@link onComponentRemoved} will be * dispatched after removing it from the entity. * * If linked component type provided: * - For each instance of linked component {@link onComponentRemoved} will be called * - Only head of the linked list will be returned. * * If you need to get all instances use {@link withdraw} or {@link pick} instead, or check {@link iterate}, * {@link getAll} * * @param componentClassOrTag Specific component class * @returns Component instance or `undefined` if it doesn't exists in the entity */ public removeComponent(componentClassOrTag: Class): T | undefined { const id = getComponentId(componentClassOrTag); if (id === undefined || this._components[id] === undefined) { return undefined; } let value = this._components[id]!; if (isLinkedComponent(value)) { const list = this.getLinkedComponentList(componentClassOrTag)!; while (!list.isEmpty) { this.withdraw(componentClassOrTag); } } else { delete this._components[id]; this.dispatchOnComponentRemoved(value); } return value as T; } /** * Removes a tag from the entity. * In case if the component tag is present - then {@link onComponentRemoved} will be * dispatched after removing it from the entity * * @param {Tag} tag Specific tag * @returns {void} */ public removeTag(tag: Tag): void { if (this._tags.has(tag)) { this._tags.delete(tag); this.dispatchOnComponentRemoved(tag); } } /** * Removes all components and tags from entity */ public clear(): void { this._components = {}; this._linkedComponents = {}; this._tags.clear(); } /** * Copies content from entity to itself. * Linked components structure will be copied by the link, because we can't duplicate linked list order without * cloning components itself. So modifying linked components in the copy will affect linked components in copy * source. * * @param {Entity} entity * @return {this} */ public copyFrom(entity: Entity): this { this._components = Object.assign({}, entity._components); this._linkedComponents = Object.assign({}, entity._linkedComponents); this._tags = new Set(entity._tags); return this; } /** * Iterates over instances of linked component appended to the Entity and performs the action over each.
* Works and for standard components (action will be called for a single instance in this case). * * @param {Class} componentClass Component`s class * @param {(component: T) => void} action Action to perform over every component instance. * @example * ```ts * class Boon extends LinkedComponent { * public constructor( * public type: BoonType, * public duration: number * ) { super(); } * } * const entity = new Entity() * .append(new Boon(BoonType.HEAL, 2)) * .append(new Boon(BoonType.PROTECTION, 3); * * // Let's decrease every boon duration and remove them if they are expired. * entity.iterate(Boon, (boon) => { * if (--boon.duration <= 0) { * entity.pick(boon); * } * }); * ``` */ public iterate(componentClass: Class, action: (component: T) => void): void { if (!this.hasComponent(componentClass)) return; this.getLinkedComponentList(componentClass)?.iterate(action); } /** * Returns generator with all instances of specified linked component class * * @param {Class} componentClass Component`s class * @example * ```ts * for (const damage of entity.linkedComponents(Damage)) { * if (damage.value < 0) { * throw new Error('Damage value can't be less than zero'); * } * ``` */ public* getAll(componentClass: Class): Generator { if (!this.hasComponent(componentClass)) return; const list = this.getLinkedComponentList(componentClass, false); if (list === undefined) return undefined; yield* list.nodes(); } /** * Searches a component instance of specified linked component class. * Works and for standard components (predicate will be called for a single instance in this case). * * @param {Class} componentClass * @param {(component: T) => boolean} predicate * @return {T | undefined} */ public find(componentClass: Class, predicate: (component: T) => boolean): T | undefined { const componentIdToFind = getComponentId(componentClass, false); if (componentIdToFind === undefined) return undefined; const component = this._components[componentIdToFind]; if (component === undefined) return undefined; if (isLinkedComponent(component)) { let linkedComponent: ILinkedComponent | undefined = component; while (linkedComponent !== undefined) { if (predicate(linkedComponent as T)) return linkedComponent as T; linkedComponent = linkedComponent.next; } } else return predicate(component as T) ? component as T : undefined; } /** * Returns number of components of specified class. * * @param {Class} componentClass * @return {number} */ public lengthOf(componentClass: Class): number { let result = 0; this.iterate(componentClass, () => { result++; }); return result; } /** * Use this method to dispatch that entity component properties were changed, in case if * queries predicates are depends on them. * Components properties are not tracking by Engine itself, because it's too expensive. */ public invalidate(): void { this.onInvalidationRequested.emit(this); } /** * @internal * @param {EntitySnapshot} result * @param {T} changedComponentOrTag * @param {Class} resolveClass */ public takeSnapshot(result: EntitySnapshot, changedComponentOrTag?: T, resolveClass?: Class): void { const previousState = result.previous as Entity; if (result.current !== this) { result.current = this; previousState.copyFrom(this); } if (changedComponentOrTag === undefined) { return; } if (isTag(changedComponentOrTag)) { const previousTags = previousState._tags; if (this.has(changedComponentOrTag)) { previousTags.delete(changedComponentOrTag); } else { previousTags.add(changedComponentOrTag); } } else { const componentClass = resolveClass ?? Object.getPrototypeOf(changedComponentOrTag).constructor; const componentId = getComponentId(componentClass!, true)!; const previousComponents = previousState._components; if (this.has(componentClass)) { delete previousComponents[componentId]; } else { previousComponents[componentId] = changedComponentOrTag; } } } /** * @internal */ public getLinkedComponentList(componentClassOrId: number | Class, createIfNotExists = true): LinkedComponentList | undefined { if (typeof componentClassOrId !== 'number') { componentClassOrId = getComponentId(componentClassOrId)!; } if (this._linkedComponents[componentClassOrId] !== undefined || !createIfNotExists) { return this._linkedComponents[componentClassOrId]; } else { return this._linkedComponents[componentClassOrId] = new LinkedComponentList(); } } private withdrawComponent(component: NonNullable, resolveClass?: Class): T | undefined { const componentClass = getComponentClass(component, resolveClass); const componentList = this.getLinkedComponentList(componentClass, false); if (!this.hasComponent(componentClass) || componentList === undefined) return undefined; const result = componentList.remove(component) ? component : undefined; const componentId = getComponentId(componentClass, true)!; if (componentList.isEmpty) { delete this._components[componentId]; delete this._linkedComponents[componentId]; } else { this._components[componentId] = componentList.head; } if (result !== undefined) { this.dispatchOnComponentRemoved(result); } return result; } private dispatchOnComponentAdded(component: NonNullable): void { if (this.onComponentAdded.hasHandlers) { this.onComponentAdded.emit(this, component); } } private dispatchOnComponentRemoved(value: NonNullable): void { if (this.onComponentRemoved.hasHandlers) { this.onComponentRemoved.emit(this, value); } } } /** * EntitySnapshot is a content container that displays the difference between the current state of Entity and its * previous state. * * The {@link EntitySnapshot.current} property always reflects the current state, and {@link EntitySnapshot.previous} - * previous one. So you can understand which components have been added and which have been removed. * *

It is important to note that changes in the data of the same entity components will not be reflected in the * snapshot, even if a manual invalidation of the entity has been triggered.

*/ export class EntitySnapshot { private _current?: Entity; private _previous: ReadonlyEntity = new Entity(); /** * Gets an instance of the actual entity * @returns {Entity} */ public get current(): Entity { return this._current!; } /** * @internal */ public set current(value: Entity) { this._current = value; } /** * Gets an instance of the previous state of entity */ public get previous(): ReadonlyEntity { return this._previous; } } /** * Component update handler type. * @see {@link Entity.onComponentAdded} * @see {@link Entity.onComponentRemoved} */ export type ComponentUpdateHandler = (entity: Entity, component: NonNullable, componentClass?: Class>) => void; /** * Entity ids enumerator */ let entityId: number = 1; ================================================ FILE: src/ecs/IterativeSystem.ts ================================================ import {Query, QueryBuilder, QueryPredicate} from './Query'; import {Entity} from './Entity'; import {ReactionSystem} from './ReactionSystem'; /** * Iterative system made for iterating over entities that matches its query. * * @example * You have a View component, that is responsible for entity displaying and contains an image. * So every step you want to update image positions, that can depends on Position component. * * ```ts * class ViewSystem extends IterativeSystem { * constructor(container:Container) { * super(new Query((entity:Entity) => entity.hasAll(View, Position)); * this.container = container; * } * * // Update entity view position on screen, via position component data * updateEntity(entity:Entity) { * const {view} = entity.get(View)!; * const {x, y) = entity.get(Position)!; * view.x = x; * view.y = y; * } * * // Add entity view from screen * entityAdded = ({entity}:EntitySnapshot) => { * this.container.add(entity.get(View)!.view); * } * * // Remove entity view from screen * entityRemoved = (snapshot:EntitySnapshot) => { * this.container.remove(snapshot.get(View)!.view); * } * } * ``` */ export abstract class IterativeSystem extends ReactionSystem { private _removed: boolean = false; protected constructor(query: Query | QueryBuilder | QueryPredicate) { super(query); } public update(dt: number) { this.updateEntities(dt); } public onAddedToEngine() { this._removed = false; super.onAddedToEngine(); } public onRemovedFromEngine() { this._removed = true; super.onRemovedFromEngine(); } protected updateEntities(dt: number) { for (let entity of this.query.entities) { if (this._removed) return; this.updateEntity(entity, dt); } } /** * Update entity * * @param entity Entity to update * @param dt Delta time in seconds */ protected abstract updateEntity(entity: Entity, dt: number): void; } ================================================ FILE: src/ecs/LinkedComponent.ts ================================================ /** * Linked list interface for linked components * @see {@link Entity.append} */ export interface ILinkedComponent { id?: string; next?: ILinkedComponent; } /** * Simple ILinkedComponent implementation * @see {@link Entity.append} */ export class LinkedComponent implements ILinkedComponent { public next?: this = undefined; public constructor(public id?: string) { } } /** * @internal */ export function isLinkedComponent(component: any): component is ILinkedComponent { return component !== undefined && component.hasOwnProperty('next'); } ================================================ FILE: src/ecs/LinkedComponentList.ts ================================================ import {ILinkedComponent} from './LinkedComponent'; export class LinkedComponentList { private _head?: T; public get head(): T | undefined { return this._head; } public get isEmpty(): boolean { return this._head === undefined; } public add(linkedComponent: T): void { let prev: T | undefined = undefined; let current: T | undefined = this._head; while (current !== undefined) { if (current === linkedComponent) { throw new Error('Component is already appended, appending it once again will break linked items order'); } prev = current; current = current.next as (T | undefined); } if (this._head === undefined) { this._head = linkedComponent; } else { prev!.next = linkedComponent; } } public remove(linkedComponent: T): boolean { const [prev, current] = this.find(linkedComponent); if (current === undefined) { return false; } if (prev === undefined) { this._head = current.next as (T | undefined); } else { prev.next = current.next; } return true; } public* nodes() { let node = this.head; while (node !== undefined) { yield node; node = node.next as (T | undefined); } } public iterate(action: (value: T) => void): void { for (const node of this.nodes()) { action(node); } } public clear(): void { this._head = undefined; } private find(linkedComponent: T): [prev: T | undefined, current: T | undefined] { let prev: T | undefined; let current: T | undefined = this._head; while (current !== undefined) { if (current === linkedComponent) { return [prev, current]; } prev = current; current = current.next as (T | undefined); } return [undefined, undefined]; } } ================================================ FILE: src/ecs/Query.ts ================================================ import {getComponentId} from './ComponentId'; import {Entity, EntitySnapshot} from './Entity'; import {isTag, Tag} from './Tag'; import {Signal} from '../utils/Signal'; import {Class} from '../utils/Class'; /** * Query Predicate is the type that describes a function that compares Entities with the conditions it sets. * In other words, it's a function that determines whether Entities meets the right conditions to get into a * given Query or not. */ export type QueryPredicate = (entity: Entity) => boolean; /** * Query represents list of entities that matches query request. * @see QueryBuilder */ export class Query { /** * Signal dispatches if new matched entity were added */ public onEntityAdded: Signal<(snapshot: EntitySnapshot) => void> = new Signal(); /** * Signal dispatches if entity stops matching query */ public onEntityRemoved: Signal<(snapshot: EntitySnapshot) => void> = new Signal(); private readonly _snapshot: EntitySnapshot = new EntitySnapshot(); private readonly _predicate: QueryPredicate; private _entities: Entity[] = []; /** * Initializes Query instance * @param predicate Matching predicate */ public constructor(predicate: QueryPredicate) { this._predicate = predicate; } /** * Entities list which matches the query */ public get entities(): ReadonlyArray { return this._entities; } /** * Returns the first entity in the query or `undefined` if query is empty. * @returns {Entity | undefined} */ public get first(): Entity | undefined { if (this._entities.length === 0) return undefined; return this._entities[0]; } /** * Returns the last entity in the query or `undefined` if query is empty. * @returns {Entity | undefined} */ public get last(): Entity | undefined { if (this._entities.length === 0) return undefined; return this._entities[this._entities.length - 1]; } /** * Returns the number of the entities in the query * @returns {Entity | undefined} */ public get length(): number { return this._entities.length; } /** * Returns the number of entities that have been tested by the predicate. * @param {(entity: Entity) => boolean} predicate * @returns {number} */ public countBy(predicate: QueryPredicate): number { let result = 0; for (const entity of this._entities) { if (predicate(entity)) result++; } return result; } /** * Returns the first entity from the query, that was accepted by predicate * @param {(entity: Entity) => boolean} predicate - function that will be called for every entity in the query until * the result of the function become true. * @returns {Entity | undefined} */ public find(predicate: QueryPredicate): Entity | undefined { return this._entities.find(predicate); } /** * Returns new array of entities, which passed testing via predicate * @param {(entity: Entity) => boolean} predicate - function that will be called for every entity in the query. * If function returns `true` - entity will stay in the array, if `false` than it will be removed. * @returns {Entity[]} */ public filter(predicate: QueryPredicate): Entity[] { return this._entities.filter(predicate); } /** * Returns a value that indicates whether the entity is in the Query. * @param {Entity} entity * @returns {boolean} */ public has(entity: Entity): boolean { return this._entities.indexOf(entity) !== -1; } /** * This method is matching passed list of entities with predicate of the query to determine * if entities are the part of query or not. * * Entities that will pass testing will become a part of the query */ public matchEntities(entities: ReadonlyArray) { entities.forEach((entity) => this.entityAdded(entity)); } /** * Gets a value indicating that query is empty */ public get isEmpty(): boolean { return this.entities.length == 0; } /** * Clears the list of entities of the query */ public clear(): void { this._entities = []; } /** * @internal */ public validateEntity(entity: Entity): void { const index = this._entities.indexOf(entity); const isMatch = this._predicate(entity); if (index !== -1 && !isMatch) { this.entityRemoved(entity); } else { this.entityAdded(entity); } } /** * @internal */ public entityAdded = (entity: Entity) => { const index = this._entities.indexOf(entity); if (index === -1 && this._predicate(entity)) { this._entities.push(entity); if (this.onEntityAdded.hasHandlers) { entity.takeSnapshot(this._snapshot); this.onEntityAdded.emit(this._snapshot); } } }; /** * @internal */ public entityRemoved = (entity: Entity) => { const index = this._entities.indexOf(entity); if (index !== -1) { this._entities.splice(index, 1); if (this.onEntityRemoved.hasHandlers) { entity.takeSnapshot(this._snapshot); this.onEntityRemoved.emit(this._snapshot); } } }; /** * @internal */ public entityComponentAdded = (entity: Entity, componentOrTag: NonNullable, componentClass?: Class>) => { const hasAddedHandlers = this.onEntityAdded.hasHandlers; const hasRemovedHandlers = this.onEntityRemoved.hasHandlers; const index = this._entities.indexOf(entity); const isMatch = this._predicate(entity); if (index === -1 && isMatch) { this._entities.push(entity); if (hasAddedHandlers) { entity.takeSnapshot(this._snapshot, componentOrTag, componentClass); this.onEntityAdded.emit(this._snapshot); } } else if (index !== -1 && !isMatch) { this._entities.splice(index, 1); if (hasRemovedHandlers) { entity.takeSnapshot(this._snapshot, componentOrTag, componentClass); this.onEntityRemoved.emit(this._snapshot); } } }; /** * @internal */ public entityComponentRemoved = (entity: Entity, component: NonNullable, componentClass?: Class>) => { const hasAddedHandlers = this.onEntityAdded.hasHandlers; const hasRemovedHandlers = this.onEntityRemoved.hasHandlers; const index = this._entities.indexOf(entity); const isMatch = this._predicate(entity); if (index !== -1 && !isMatch) { this._entities.splice(index, 1); if (hasRemovedHandlers) { entity.takeSnapshot(this._snapshot, component, componentClass); this.onEntityRemoved.emit(this._snapshot); } } else if (index === -1 && isMatch) { this._entities.push(entity); if (hasAddedHandlers) { entity.takeSnapshot(this._snapshot, component, componentClass); this.onEntityAdded.emit(this._snapshot); } } }; } function hasAll(entity: Entity, components: Set, tags: Set): boolean { if (components.size > 0) { for (const componentId of components) { if (entity.components[componentId] === undefined) { return false; } } } if (tags.size > 0) { for (const tag of tags) { if (!entity.tags.has(tag)) { return false; } } } return true; } /** * Query builder, helps to create queries * @example * const query = new QueryBuilder() * .contains(Position) * .contains(Acceleration) * .contains(TorqueForce) * .build(); */ export class QueryBuilder { private readonly _components: Set = new Set(); private readonly _tags: Set = new Set(); /** * Specifies components that must be added to entity to be matched * @param componentsOrTags */ public contains(...componentsOrTags: Array): QueryBuilder { for (const componentOrTag of componentsOrTags) { if (isTag(componentOrTag)) { if (!this._tags.has(componentOrTag)) { this._tags.add(componentOrTag); } } else { const componentId = getComponentId(componentOrTag, true)!; if (!this._components.has(componentId)) { this._components.add(componentId); } } } return this; } /** * Build query */ public build(): Query { return new Query((entity: Entity) => hasAll(entity, this._components, this._tags)); } /** * @internal */ public getComponents(): ReadonlySet { return this._components; } /** * @internal */ public getTags(): ReadonlySet { return this._tags; } } /** * @internal */ export function isQueryPredicate(item: unknown): item is QueryPredicate { return typeof item === 'function'; } /** * @internal */ export function isQueryBuilder(item: unknown): item is QueryBuilder { return item instanceof QueryBuilder; } ================================================ FILE: src/ecs/ReactionSystem.ts ================================================ import {isQueryBuilder, isQueryPredicate, Query, QueryBuilder, QueryPredicate} from './Query'; import {Engine} from './Engine'; import {Entity, EntitySnapshot} from './Entity'; import {System} from './System'; /** * Represents a system that reacts when entities are added to or removed from its query. * `entityAdded` and `entityRemoved` will be called accordingly. * * @example * ```ts * class ViewSystem extends ReactionSystem { * constructor( * private readonly container:Container * ) { * super(new Query((entity:Entity) => entity.has(View)); * } * * // Add entity view to the screen * entityAdded = ({entity}:EntitySnapshot) => { * this.container.add(entity.get(View)!.view); * } * * // Remove entity view from screen * entityRemoved = (snapshot:EntitySnapshot) => { * this.container.remove(snapshot.get(View)!.view); * } * } * ``` */ export abstract class ReactionSystem extends System { protected readonly query: Query; protected constructor(query: Query | QueryBuilder | QueryPredicate) { super(); if (isQueryBuilder(query)) { this.query = query.build(); } else if (isQueryPredicate(query)) { this.query = new Query(query); } else { this.query = query; } } protected get entities(): ReadonlyArray { return this.query.entities; } public onAddedToEngine() { this.engine.addQuery(this.query); this.prepare(); this.query.onEntityAdded.connect(this.entityAdded); this.query.onEntityRemoved.connect(this.entityRemoved); } public onRemovedFromEngine() { this.engine.removeQuery(this.query); this.query.onEntityAdded.disconnect(this.entityAdded); this.query.onEntityRemoved.disconnect(this.entityRemoved); this.query.clear(); } protected prepare() {} /** * Method will be called for every new entity that matches system query. * You could easily override it with your own logic. * * Note: Method will not be called for already existing in query entities (at the adding system to engine phase), * only new entities will be handled * * @param entity EntitySnapshot that contains entity that was removed from query or engine, and components that it has * before adding, and component that will be added */ protected entityAdded = (entity: EntitySnapshot) => { }; /** * Method will be called for every entity matches system query, that is going to be removed from engine, or it stops * matching to the query. * You could easily override it with your own logic. * * @param entity EntitySnapshot that contains entity that was removed from query or engine, and components that it has * before removing */ protected entityRemoved = (entity: EntitySnapshot) => { }; } ================================================ FILE: src/ecs/Subscription.ts ================================================ import {Class} from '../utils/Class'; /** * @internal */ export class Subscription { public constructor( public readonly messageType: Class | T, public readonly handler: (message: T) => void, ) {} public equals(messageType: Class | T, handler?: (message: T) => void) { return this.messageType === messageType && (handler === undefined || this.handler === handler); } } ================================================ FILE: src/ecs/System.ts ================================================ import {Engine} from './Engine'; import {Entity} from './Entity'; /** * Systems are logic bricks in your application. * If you want to manipulate entities and their components - it is the right place for that. */ export abstract class System { private _priority: number = 0; private _engine?: Engine; private _isRemovalRequested: boolean = false; /** * Gets an {@link Engine} instance that system attached to * @returns {Engine} * @throws An error if system is not attached to the engine */ public get engine(): Engine { if (this._engine === undefined) throw new Error(`Property "engine" can't be accessed when system is not added to the engine`); return this._engine; } /** * Indicates that system should be removed from engine at the end of the current update cycle * @internal * @returns {boolean} */ public get isRemovalRequested(): boolean { return this._isRemovalRequested; } /** * Gets an {@link Entity} instance that is shared across all systems and can be used as a config. * @return {Entity} */ protected get sharedConfig(): Entity { if (this._engine === undefined) throw new Error(`Property "sharedConfig" can't be accessed when system is not added to the engine`); return this._engine.sharedConfig; } /** * Gets a priority of the system */ public get priority(): number { return this._priority; } /** * All logic aimed at making changes in entities and their components must be placed in this method. * @param dt - The time in seconds it took from previous update call. */ public update(dt: number) {} /** * This method will be called after the system will be added to the Engine. */ public onAddedToEngine() {} /** * Callback that will be invoked after removing system from engine */ public onRemovedFromEngine() {} /** * Dispatches a message, that can be caught via {@link Engine#subscribe}. * It's the best way to send a message outside. This mechanism allows you not to invent the signals/dispatchers * mechanism for your systems, to report an event. For example, you can dispatch that the game round has been * completed. * * @param {T} message * @throws An error if system is not attached to the engine * @example * ```ts * class RoundCompleted { * public constructor( * public readonly win:boolean * ) {} * } * * const engine = new Engine(); * engine.subscribe(RoundCompleted, (message:RoundCompleted) => { * if (message.win) { * this.showWinDialog(); * } else { * this.showLoseDialog(); * } * }) * * class RoundCompletionSystem extends System { * public update(dt:number) { * if (heroesQuery.isEmpty) { * this.dispatch(new RoundCompleted(false)); * } else if (enemiesQuery.isEmpty) { * this.dispatch(new RoundCompleted(true)); * } * } * } * ``` */ public dispatch(message: T): void { if (this._engine === undefined) { throw new Error('Dispatching a message can\'t be done while system is not attached to the engine'); } this.engine.dispatch(message); } /** * @internal */ public setEngine(engine: Engine | undefined): void { this._engine = engine; } /** * @internal */ public setPriority(priority: number): void { this._priority = priority; } protected requestRemoval(): void { this._isRemovalRequested = true; } } ================================================ FILE: src/ecs/Tag.ts ================================================ /** * A tag is a simple marker that can be considered as a component without data. * It can be used instead of creating a new component class, when you don't need an additional data. */ export type Tag = number | string; /** * This predicate can help you to understand whether item is a component or tag * @param item * @returns {item is Tag} */ export function isTag(item: unknown): item is Tag { const type = typeof item; return type === 'string' || type === 'number'; } ================================================ FILE: src/index.ts ================================================ export * from './utils/Class'; export * from './utils/Signal'; export * from './ecs/ComponentId'; export * from './ecs/Tag'; export * from './ecs/LinkedComponent'; export * from './ecs/Engine'; export * from './ecs/Entity'; export * from './ecs/System'; export * from './ecs/Query'; export * from './ecs/IterativeSystem'; export * from './ecs/ReactionSystem'; ================================================ FILE: src/utils/Class.ts ================================================ export type Class = { new(...args: any[]): T; }; ================================================ FILE: src/utils/Signal.ts ================================================ /** * Lightweight implementation of Signal */ export class Signal any> { private readonly handlers: SignalHandler[] = []; /** * Gets a value that indicates whether signal has handlers * @return {boolean} */ public get hasHandlers(): boolean { return this.handlers.length > 0; } /** * Gets an amount of connected handlers * @return {number} */ public get handlersAmount(): number { return this.handlers.length; } /** * Connects signal handler, that will be invoked on signal emit. * @param {Handler} handler * @param priority Handler invocation priority (handler with higher priority will be called later than with lower one) */ public connect(handler: Handler, priority: number = 0): void { const existingHandler = this.handlers.find((it) => it.equals(handler)); let needResort: boolean; if (existingHandler !== undefined) { needResort = existingHandler.priority !== priority; existingHandler.priority = priority; } else { const lastHandler = this.handlers[this.handlers.length - 1]; this.handlers.push(new SignalHandler(handler, priority)); needResort = (lastHandler !== undefined && lastHandler.priority > priority); } if (needResort) { this.handlers.sort((a, b) => a.priority - b.priority); } } /** * Disconnects signal handler * @param {Handler} handler */ public disconnect(handler: Handler): void { const existingHandlerIndex = this.handlers.findIndex((it) => it.equals(handler)); if (existingHandlerIndex >= 0) { this.handlers.splice(existingHandlerIndex, 1); } } /** * Disconnects all signal handlers * @param {Handler} handler */ public disconnectAll(): void { this.handlers.length = 0; } /** * Invokes connected handlers with passed parameters. * @param {any} args */ public emit(...args: Parameters): void { for (const handler of this.handlers) { handler.handle(...args); } } } class SignalHandler any> { public constructor(public readonly handler: Handler, public priority: number) {} public equals(handler: Handler): boolean { return this.handler === handler; } public handle(...args: any[]) { this.handler(...args); } } ================================================ FILE: tests/unit/engine.spec.ts ================================================ import {Engine, Entity, IterativeSystem, LinkedComponent, Query, QueryBuilder, QueryPredicate, System, ReactionSystem } from '../../src'; class Component {} class Message {} const handler1 = (message: Message) => {}; const handler2 = (message: Message) => {}; const handler3 = (message: Message) => {}; abstract class TestSystem extends IterativeSystem { protected constructor( query: Query | QueryBuilder | QueryPredicate, private readonly arr?: number[], ) { super(query); this.arr = arr; } public update(dt: number) { super.update(dt); if (this.arr !== undefined) { this.arr.push(this.priority); } } protected updateEntity(entity: Entity, dt: number): void { } } class TestSystem1 extends TestSystem { public constructor(arr?: number[]) { super(new Query((entity: Entity) => entity.has(Component)), arr); } } class TestSystem2 extends TestSystem { public constructor(arr?: number[]) { super((entity: Entity) => entity.has(Component), arr); } } class TestSystem3 extends TestSystem { public constructor(arr?: number[]) { super(new QueryBuilder().contains(Component), arr); } } describe('System manipulation', () => { it('Engine system creating', () => { const engine = new Engine(); expect(engine.systems).toBeDefined(); expect(engine.systems.length).toBe(0); expect(engine.entities).toBeDefined(); expect(engine.entities.length).toBe(0); expect(engine.queries).toBeDefined(); expect(engine.queries.length).toBe(0); }); it('Adding system', () => { const engine = new Engine(); const system = new TestSystem1(); engine.addSystem(system); expect(engine.systems.length).toBe(1); expect(engine.getSystem(TestSystem1)).toBe(system); }); it('Adding and removing multiple system with priority', () => { const engine = new Engine(); const system1 = new TestSystem1(); const system2 = new TestSystem2(); const system3 = new TestSystem3(); engine.addSystem(system1, 200); engine.addSystem(system2, 300); engine.addSystem(system3, 100); expect(engine.systems.length).toBe(3); expect(engine.getSystem(TestSystem1)).toBe(system1); expect(engine.getSystem(TestSystem2)).toBe(system2); expect(engine.getSystem(TestSystem3)).toBe(system3); expect(engine.systems).toEqual([system3, system1, system2]); engine.removeAllSystems(); expect(engine.systems.length).toBe(0); }); it('Adding multiple systems with same priority must added in same order', () => { const engine = new Engine(); const system1 = new TestSystem1(); const system2 = new TestSystem2(); const system3 = new TestSystem3(); engine.addSystem(system1); engine.addSystem(system2); engine.addSystem(system3); expect(engine.systems.length).toBe(3); expect(engine.systems).toEqual([system1, system2, system3]); }); it('Remove system', () => { const engine = new Engine(); const system = new TestSystem1(); engine.addSystem(system); expect(engine.systems.length).toBe(1); expect(engine.getSystem(TestSystem1)).toBe(system); engine.removeSystem(system); expect(engine.systems.length).toBe(0); expect(engine.getSystem(TestSystem1)).toBeUndefined(); }); it(`Expected that removing not attached system will not throw an error`, () => { const engine = new Engine(); const system = new TestSystem1(); expect(() => { engine.removeSystem(system);}).not.toThrowError(); }); it('Engine updating', () => { const engine = new Engine(); const arr: number[] = []; const system1 = new TestSystem1(arr); const system2 = new TestSystem2(arr); const system3 = new TestSystem3(arr); engine.addSystem(system1, 1); engine.addSystem(system2, 2); engine.addSystem(system3, 3); engine.update(1); expect(arr).toEqual([1, 2, 3]); }); it('Engine#clear should remove entities, systems, remove and clear queries', () => { class TestSystem extends IterativeSystem { public constructor() { super(new Query(entity => true)); } protected updateEntity(entity: Entity, dt: number): void { } } const engine = new Engine(); const query = new Query(entity => entity.has(Component)); const system = new TestSystem(); engine.addQuery(query); engine.addSystem(system); engine.addEntity(new Entity().add(new Component())); expect(query.isEmpty).toBeFalsy(); engine.clear(); expect(engine.systems.length).toBe(0); expect(engine.queries.length).toBe(0); expect(engine.entities.length).toBe(0); expect(query.isEmpty).toBeTruthy(); engine.addEntity(new Entity().add(new Component())); expect(query.isEmpty).toBeTruthy(); }); it('Expected that removing all entities will fire onEntityRemoved', () => { const engine = new Engine(); const entitiesCount = 2; let removedCount = 0; for (let i = 0; i < entitiesCount; i++) { engine.addEntity(new Entity()); } engine.onEntityRemoved.connect(() => removedCount++); engine.removeAllEntities(); expect(engine.entities.length).toBe(0); expect(removedCount).toBe(entitiesCount); }); it(`Expected that engine will not add same handler twice for the same message`, () => { const engine = new Engine(); const handler = (message: Message) => {}; const subscription1 = engine.subscribe(Message, handler); const subscription2 = engine.subscribe(Message, handler); expect(subscription1).toBe(subscription2); }); it(`Expected that unsubscribe removes specific subscription`, () => { const engine = new Engine(); engine.subscribe(Message, handler1); engine.subscribe(Message, handler2); engine.subscribe(Message, handler3); expect(engine.subscriptions.length).toBe(3); engine.unsubscribe(Message, handler1); expect(engine.subscriptions.length).toBe(2); }); it(`Expected that unsubscribe removes all relevant subscriptions`, () => { const engine = new Engine(); engine.subscribe(Message, handler1); engine.subscribe(Message, handler2); engine.subscribe(Message, handler3); expect(engine.subscriptions.length).toBe(3); engine.unsubscribe(Message); expect(engine.subscriptions.length).toBe(0); }); it(`Expected that unsubscribeAll removes all subscriptions`, () => { const engine = new Engine(); engine.subscribe(Message, handler1); engine.subscribe(Message, handler2); engine.subscribe(Message, handler3); expect(engine.subscriptions.length).toBe(3); engine.unsubscribeAll(); expect(engine.subscriptions.length).toBe(0); }); it(`Expected that system\`s message will be delivered through the engine to the handler`, () => { const HERO = 'hero'; const GAME_OVER = 'gameOver'; class GameOverSystem extends ReactionSystem { private dispatched: boolean = false; public constructor() { super((entity: Entity) => entity.has(HERO)); } public update(dt: number) { if (this.dispatched) return; if (!this.query.isEmpty && !this.dispatched) { this.dispatch(GAME_OVER); this.dispatched = true; } } protected prepare() { this.dispatched = false; } } let gameOverReceived = false; const engine = new Engine(); const system = new GameOverSystem(); engine.subscribe(GAME_OVER, () => { gameOverReceived = true; }); engine.addSystem(system); engine.addEntity(new Entity().add(HERO)); engine.addEntity(new Entity().add(HERO)); engine.update(1); engine.removeAllEntities(); engine.update(1); expect(gameOverReceived).toBeTruthy(); }); it(`Expected that system\`s message will be delivered through the engine to the handler`, () => { const HERO = 'hero'; class GameOver {} class OtherMessage {} class GameOverSystem extends ReactionSystem { private dispatched: boolean = false; public constructor() { super((entity: Entity) => entity.has(HERO)); } public update(dt: number) { if (this.dispatched) return; if (!this.query.isEmpty && !this.dispatched) { this.dispatch(new GameOver()); this.dispatched = true; } } protected prepare() { this.dispatched = false; } } let gameOverReceived = false; let otherMessageReceived = false; const engine = new Engine(); const system = new GameOverSystem(); engine.subscribe(GameOver, () => { gameOverReceived = true; }); engine.subscribe(OtherMessage, () => { otherMessageReceived = true; }); engine.addSystem(system); engine.addEntity(new Entity().add(HERO)); engine.addEntity(new Entity().add(HERO)); engine.update(1); engine.removeAllEntities(); engine.update(1); expect(gameOverReceived).toBeTruthy(); expect(otherMessageReceived).toBeFalsy(); }); it(`Expected that removing of not attached query will not throw an error`, () => { const TAG = 1; const query = new Query((entity: Entity) => entity.has(TAG)); const engine = new Engine(); expect(() => {engine.removeQuery(query);}).not.toThrowError(); }); it(`Expected that adding the same entity twice will add it only once`, () => { const entity = new Entity(); const engine = new Engine(); engine.addEntity(entity); engine.addEntity(entity); expect(engine.entities.length).toBe(1); }); it(`Expected that removing an entity that wasn't added to engine will do nothing`, () => { const entity1 = new Entity(); const entity2 = new Entity(); const engine = new Engine(); engine.addEntity(entity1); engine.removeEntity(entity2); let entityRemovedCount = 0; engine.onEntityRemoved.connect((entity) => { entityRemovedCount++; }); expect(engine.entities.length).toBe(1); expect(engine.entities[0]).toBe(entity1); expect(entityRemovedCount).toBe(0); }); it('Getting entity by id from engine should success if entity is in the engine', () => { const engine = new Engine(); const entity = new Entity(); const id = entity.id; engine.addEntity(entity); expect(engine.getEntityById(id)).toBe(entity); }); it('Getting entity by id from engine should fail if entity is not in the engine', () => { const engine = new Engine(); const entity = new Entity(); const id = entity.id; engine.addEntity(entity); engine.removeEntity(entity); expect(engine.getEntityById(id)).toBeUndefined(); }); }); ================================================ FILE: tests/unit/entity.spec.ts ================================================ import {Entity, EntitySnapshot, getComponentId, LinkedComponent} from '../../src'; class Position { public x: number = 0; public y: number = 0; constructor(x: number = 0, y: number = 0) { this.x = x; this.y = y; } } class Damage extends LinkedComponent { public constructor( public value: number, id?: string, ) { super(id); } } class AnotherDamage extends LinkedComponent { public constructor() { super(); } } class DamageChild extends Damage { public constructor() { super(1); } } describe('Components id', () => { it('Getting component id without forcing of id creation returns undefined', () => { expect(getComponentId( class Test { }, )).toBeUndefined(); }); it('Getting component id return equal values for same component twice', () => { class Test1 { } class Test2 { } expect(getComponentId(Test1, true)) .toBe(getComponentId(Test1, true)); expect(getComponentId(Test2, true)) .toBe(getComponentId(Test2)); }); it('Getting components id returns different values', () => { class Test1 { } class Test2 { } const positionId = getComponentId(Test1, true); const viewId = getComponentId(Test2, true); expect(positionId).toBeDefined(); expect(viewId).toBeDefined(); expect(positionId == viewId).toBeFalsy(); }); }); describe('Components and Tags', () => { it('Adding single component, must to dispatch only onComponentAdded once', () => { const entity = new Entity(); let addedCount = 0; let removedCount = 0; expect(entity.has(Position)).toBe(false); const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); entity.add(new Position()); entity.onComponentAdded.disconnect(addedCallback); entity.onComponentRemoved.disconnect(removedCallback); expect(entity.has(Position)).toBe(true); expect(addedCount).toBe(1); expect(removedCount).toBe(0); }); it('Adding component twice, must override previous component', () => { const entity = new Entity(); let addedCount = 0; let removedCount = 0; const position1 = new Position(0, 0); const position2 = new Position(1, 1); const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); entity.add(position1); entity.add(position2); entity.onComponentAdded.disconnect(addedCallback); entity.onComponentRemoved.disconnect(removedCallback); expect(entity.get(Position)).toBe(position2); expect(entity.getComponents().length).toBe(1); expect(addedCount).toBe(2); expect(removedCount).toBe(1); }); it(`Adding the same component, must not trigger onComponentAdded for the second call`, () => { const entity = new Entity(); let addedCount = 0; let removedCount = 0; const position = new Position(0, 0); const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); entity.add(position); entity.add(position); entity.onComponentAdded.disconnect(addedCallback); entity.onComponentRemoved.disconnect(removedCallback); expect(entity.get(Position)).toBe(position); expect(entity.getComponents().length).toBe(1); expect(addedCount).toBe(1); expect(removedCount).toBe(0); }); it(`Adding component with 'resolve class' ancestor`, () => { class Ancestor {} class Descendant extends Ancestor {} class Descendant2 extends Ancestor {} const entity = new Entity(); entity.add(new Descendant(), Ancestor); const id1 = getComponentId(Ancestor); const id2 = getComponentId(Descendant); expect(id1).not.toEqual(id2); expect(entity.has(Ancestor)).toBeTruthy(); expect(entity.get(Ancestor)).toBeDefined(); expect(entity.has(Descendant)).toBeFalsy(); expect(entity.get(Descendant)).toBeUndefined(); expect(entity.has(Descendant2)).toBeFalsy(); expect(entity.get(Descendant2)).toBeUndefined(); }); it(`Adding component with 'resolve class' not ancestor`, () => { class Ancestor {} class Descendant extends Ancestor {} class Other {} const entity = new Entity(); expect( () => { entity.add(new Ancestor(), Descendant); }, ).toThrow(); expect( () => { entity.add(new Ancestor(), Other); }, ).toThrow(); }); it(`Adding component of type Ancestor should override component with 'resolve class' Ancestor`, () => { class Ancestor {} class Descendant extends Ancestor {} const entity = new Entity(); const ancestor = new Ancestor(); const descendant = new Descendant(); entity.add(descendant, Ancestor); expect(entity.get(Ancestor)).toBe(descendant); entity.add(ancestor); expect(entity.has(Ancestor)).toBeTruthy(); expect(entity.get(Ancestor)).toBe(ancestor); }); it('Expected that hasAny returns true from component', () => { class Other {} const entity = new Entity(); entity.add(new Position()); expect(entity.hasAny(Other, Position)).toBeTruthy(); }); it('Expected that hasAny returns false', () => { class Other {} class A {} const TAG = 'tag'; const entity = new Entity(); entity.add(new A()); entity.add(TAG); expect(entity.hasAny(Other, Position)).toBeFalsy(); }); it('Expected that hasAll returns true', () => { const entity = new Entity(); const TAG = 12345; entity.add(new Position()); entity.add(TAG); expect(entity.hasAll(TAG, Position)).toBeTruthy(); }); it('Expected that hasAll returns false', () => { class Other {} const entity = new Entity(); entity.add(new Position()); expect(entity.hasAll(Other, Position)).toBeFalsy(); }); it(`Expected that adding a tag dispatches onComponentAdded once`, () => { const TAG = 0; let addedCount = 0; let removedCount = 0; const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; const entity = new Entity(); entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); entity.add(TAG); const tags = entity.getTags(); expect(addedCount).toBe(1); expect(entity.tags.size).toBe(1); expect(tags.length).toBe(1); expect(removedCount).toBe(0); }); it(`Expected that adding a tag twice dispatches onComponentAdded only once`, () => { const TAG = 0; let addedCount = 0; let removedCount = 0; const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; const entity = new Entity(); entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); entity.add(TAG); entity.add(TAG); expect(addedCount).toBe(1); expect(removedCount).toBe(0); }); it(`Expected that entity has an added tag`, () => { const TAG = 0; const entity = new Entity(); entity.add(TAG); expect(entity.has(TAG)).toBeTruthy(); }); it(`Expected that appending the same linked component twice will throw an error`, () => { const entity = new Entity(); const damage = new Damage(10); expect(() => { entity.append(damage); entity.append(damage); }).toThrowError(); }); it(`Expected that specifying not ancestor as a resolve class for appended component throws an error`, () => { const entity = new Entity(); expect(() => { entity.append(new Damage(10), AnotherDamage); }).toThrow(); expect(() => { entity.append(new Damage(10), DamageChild); }).toThrow(); }); it(`Expected that specifying resolve class for appended component gives right resolving`, () => { const entity = new Entity(); const secondChild = new DamageChild(); const firstChild = new DamageChild(); entity.append(firstChild, Damage); entity.append(secondChild, Damage); expect(entity.get(Damage)).toEqual(firstChild); }); it(`Expected that appending the same linked component twice with gaps will throw an error`, () => { const entity = new Entity(); const damage = new Damage(10); expect(() => { entity.append(damage); for (let i = 0; i < 5; i++) { entity.append(new Damage(i)); } entity.append(damage); }).toThrowError(); }); it(`Expected that appending the two different instances of linked component will not throw an error`, () => { const entity = new Entity(); expect(() => { entity.append(new Damage(10)); entity.append(new Damage(10)); }).not.toThrowError(); }); it(`Expected that appending the two different instances of linked component will trigger onComponentAdded only once`, () => { const entity = new Entity(); let addedAmount = 0; entity.onComponentAdded.connect(() => { addedAmount++; }); entity.append(new Damage(10)); entity.append(new Damage(10)); expect(addedAmount).toBe(2); }); it(`Removing linked component with "remove" removes whole linked list`, () => { const entity = new Entity(); entity.append(new Damage(10)); entity.append(new Damage(10)); entity.remove(Damage); expect(entity.get(Damage)).toBeUndefined(); }); it(`Removing linked component with "pick" removes only first component`, () => { const entity = new Entity(); const damage1 = new Damage(1); const damage2 = new Damage(2); entity.append(damage1); entity.append(damage2); entity.pick(damage1); expect(entity.get(Damage)).toBe(damage2); }); it(`get LinkedComponent by id returns specific linked component instance`, () => { const entity = new Entity(); const damage1 = new Damage(1); const damage2 = new Damage(2, 'ka-boom'); entity.append(damage1); entity.append(damage2); expect(entity.get(Damage, 'ka-boom')).toBe(damage2); }); it(`get regular component by id returns always undefined`, () => { const entity = new Entity(); entity.add(new Position()); expect(entity.get(Position, 'ka-boom')).toBeUndefined(); }); it(`has LinkedComponent with id returns specific linked component instance`, () => { const entity = new Entity(); const damage1 = new Damage(1); const damage2 = new Damage(2, 'ka-boom'); entity.append(damage1); entity.append(damage2); expect(entity.has(Damage, 'ka-boom')).toBeTruthy(); }); it(`has regular Component with id always returns false`, () => { const entity = new Entity(); const position = new Position(); entity.add(position); expect(entity.has(Position, 'ka-boom')).toBeFalsy(); }); it(`"pick" by id removes component as expected`, () => { const entity = new Entity(); const damage1 = new Damage(1); const damage2 = new Damage(2, 'ka-boom'); entity.append(damage1); entity.append(damage2); const picked = entity.pick(Damage, 'ka-boom'); expect(damage2).toBe(picked); }); it(`"pick" by id won't remove anything if component is not entity`, () => { const entity = new Entity(); const damage1 = new Damage(1); const damage2 = new Damage(2); entity.append(damage1); entity.append(damage2); const picked = entity.pick(Damage, 'ka-boom'); expect(picked).toBeUndefined(); }); it(`Withdrawing all components clears linked list associated to component class`, () => { const entity = new Entity() .append(new Damage(1)) .append(new Damage(2)) .append(new Damage(3)); while (entity.has(Damage)) { entity.withdraw(Damage); } expect(entity.has(Damage)).toBeFalsy(); expect(entity.getLinkedComponentList(Damage, false)).toBeUndefined(); }); it(`"withdraw" returns undefined if there is no linked components appended`, () => { const entity = new Entity() .add(new Position()) .append(new Damage(1)) .append(new Damage(2)); while (entity.has(Damage)) { entity.withdraw(Damage); } expect(entity.withdraw(Damage)).toBeUndefined(); }); it('"contains" returns the same instance if it exists in the linked components appended to the Entity', () => { const damage = new Damage(1); const entity = new Entity() .append(new Damage(1)) .append(damage) .append(new Damage(2)); expect(entity.contains(damage)).toBeTruthy(); }); it('"contains" returns undefined linked component is not appended to the Entity', () => { const damage = new Damage(1); const entity = new Entity() .append(new Damage(1)) .append(new Damage(2)); expect(entity.contains(damage)).toBeFalsy(); }); it('"contains" returns undefined for linked component registered under another resolveClass', () => { const damage = new DamageChild(); const entity = new Entity() .append(damage, Damage); expect(entity.contains(damage, DamageChild)).toBeFalsy(); }); it('"contains" works for regular components', () => { const position = new Position(1, 1); const entity = new Entity() .append(new Damage(1)) .append(new Damage(2)) .add(position); expect(entity.contains(position)).toBeTruthy(); }); it(`Linked components must be cleared after remove`, () => { const entity = new Entity(); entity.append(new Damage(1)); entity.append(new Damage(2)); entity.remove(Damage); entity.append(new Damage(3)); expect(entity.lengthOf(Damage)).toBe(1); }); it(`Find component returns linked component instance accepted by predicate`, () => { const entity = new Entity(); const damage1 = new Damage(1); const damage2 = new Damage(2); entity .append(damage1) .append(damage2); expect(entity.find(Damage, (it) => it.value === 2)).toBe(damage2); }); it(`Find component returns regular component instance accepted by predicate`, () => { const entity = new Entity(); entity.append(new Damage(1)) .add(new Position(100, 100)); expect(entity.find(Position, (it) => it.x === 100 && it.y === 100)).toBe(entity.get(Position)); }); it('Entity.linkedComponents returns all linked components instances for specific component class', () => { const entity = new Entity(); entity .append(new Damage(1)) .append(new Damage(2)) .append(new Damage(3)); let amount = 0; for (const damage of entity.getAll(Damage)) { if (damage.value === amount + 1) { amount++; } } expect(amount).toBe(3); }); }); describe('Removing component', () => { it('Simple', () => { const entity = new Entity(); const position = new Position(1, 1); let addedCount = 0; let removedCount = 0; const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); entity.add(position); const removedComponent = entity.remove(Position); entity.onComponentAdded.disconnect(addedCallback); entity.onComponentRemoved.disconnect(removedCallback); expect(entity.getComponents().length).toBe(0); expect(addedCount).toBe(1); expect(removedCount).toBe(1); expect(removedComponent).toBeDefined(); expect(removedComponent).toBe(position); }); it('Removing absent component', () => { const entity = new Entity(); let addedCount = 0; let removedCount = 0; const addedCallback = () => addedCount++; const removedCallback = () => removedCount++; entity.onComponentAdded.connect(addedCallback); entity.onComponentRemoved.connect(removedCallback); const removedComponent = entity.remove(Position); entity.onComponentAdded.disconnect(addedCallback); entity.onComponentRemoved.disconnect(removedCallback); expect(entity.getComponents().length).toBe(0); expect(addedCount).toBe(0); expect(removedCount).toBe(0); expect(removedComponent).toBeUndefined(); }); it(`Expected that entity doesn't have removed tag`, () => { const TAG = 0; const entity = new Entity(); entity.add(TAG); entity.remove(TAG); expect(entity.has(TAG)).toBeFalsy(); }); it(`Expected that removing absent tag returns undefined`, () => { const TAG = 1234; const entity = new Entity(); expect(entity.remove(TAG)).toBeUndefined(); }); it(`"withdraw" can remove regular component as well`, () => { const entity = new Entity(); const position = new Position(1, 1); const result = entity .add(position) .withdraw(Position); expect(result).toBe(position); expect(entity.has(Position)).toBeFalsy(); }); it(`"pick" can remove regular component as well`, () => { const entity = new Entity(); const position = new Position(1, 1); const result = entity .add(position) .pick(position); expect(result).toBe(position); expect(entity.has(Position)).toBeFalsy(); }); }); describe('Snapshot', () => { it(`Expected that checking tag in the blank snapshot gives false`, () => { const TAG = 1; const snapshot = new EntitySnapshot(); expect(snapshot.previous.has(TAG)).toBeFalsy(); }); it('Expect undefined value (but not throwing an error) for getting component instance, if snapshot not initialized', () => { class Component {} const snapshot = new EntitySnapshot(); expect(() => snapshot.previous.get(Component)).not.toThrowError(); expect(snapshot.previous.get(Component)).toBeUndefined(); }); it('Expect undefined value for class that was not being initialized as component', () => { class Component {} class NotAComponent {} const entity = new Entity(); entity.add(new Component()); const snapshot = new EntitySnapshot(); entity.takeSnapshot(snapshot, new Component()); expect(() => snapshot.previous.get(NotAComponent)).not.toThrowError(); expect(snapshot.previous.get(NotAComponent)).toBeUndefined(); }); it(`Expected that added component appears in current state, but not in the previous`, () => { class ComponentA {} class ComponentB {} const TAG_C = 'tag-c'; const snapshot = new EntitySnapshot(); const entity = new Entity().add(new ComponentA()); entity.onComponentAdded.connect((entity, componentOrTag) => { entity.takeSnapshot(snapshot, componentOrTag); }); { entity.add(new ComponentB()); expect(snapshot.current.has(ComponentB)).toBeTruthy(); expect(snapshot.current.get(ComponentB)).toBeDefined(); expect(snapshot.previous.has(ComponentB)).toBeFalsy(); expect(snapshot.previous.get(ComponentB)).toBeUndefined(); } { entity.add(TAG_C); expect(snapshot.current.has(TAG_C)).toBeTruthy(); expect(snapshot.previous.has(TAG_C)).toBeFalsy(); } }); it(`Expected that removed component appears in previous state, but not in the current`, () => { class ComponentA {} const TAG_C = 'tag-c'; const snapshot = new EntitySnapshot(); const entity = new Entity().add(new ComponentA()).add(TAG_C); entity.onComponentRemoved.connect((entity, componentOrTag) => { entity.takeSnapshot(snapshot, componentOrTag); }); { entity.remove(ComponentA); const current = snapshot.current; const previous = snapshot.previous; expect(current.has(ComponentA)).toBeFalsy(); expect(current.get(ComponentA)).toBeUndefined(); expect(previous.has(ComponentA)).toBeTruthy(); expect(previous.get(ComponentA)).toBeDefined(); } { entity.remove(TAG_C); expect(snapshot.current.has(TAG_C)).toBeFalsy(); expect(snapshot.previous.has(TAG_C)).toBeTruthy(); } }); it('Adding linked component must replace all existing linked component instances', () => { const entity = new Entity() .append(new Damage(1)) .append(new Damage(2)) .append(new Damage(3)); entity.add(new Damage(100)); expect(entity.lengthOf(Damage)).toBe(1); }); it('Replacing linked component with "add" must trigger onComponentRemoved for every appended linked component', () => { const entity = new Entity() .append(new Damage(1)) .append(new Damage(2)) .append(new Damage(3)); let removedNumber = 0; entity.onComponentRemoved.connect(() => { removedNumber++; }); entity.add(new Damage(100)); expect(removedNumber).toBe(3); }); }); ================================================ FILE: tests/unit/linked.list.spec.ts ================================================ import {LinkedComponentList} from '../../src/ecs/LinkedComponentList'; import {LinkedComponent} from '../../src'; class Component extends LinkedComponent {} describe('Linked list', () => { it(`Adding component to the empty list putting it to the head`, () => { const list = new LinkedComponentList(); const component = new Component(); list.add(component); expect(list.head).toBe(component); }); it(`Adding component to the empty list makes it non-empty`, () => { const list = new LinkedComponentList(); const component = new Component(); expect(list.isEmpty).toBeTruthy(); list.add(component); expect(list.isEmpty).toBeFalsy(); }); it(`Removing component from the list makes it empty`, () => { const list = new LinkedComponentList(); const component = new Component(); list.add(component); expect(list.remove(component)).toBeTruthy(); expect(list.isEmpty).toBeTruthy(); }); it(`Removing component from the the empty list returns false`, () => { const list = new LinkedComponentList(); const component = new Component(); expect(list.remove(component)).toBeFalsy(); }); it(`Removing not head component from the the list not makes it empty`, () => { const list = new LinkedComponentList(); const component1 = new Component(); const component2 = new Component(); list.add(component1); list.add(component2); list.remove(component2); expect(list.isEmpty).toBeFalsy(); }); it(`"iterate" iterates through all components in the list`, () => { const list = new LinkedComponentList(); const components = [new Component(), new Component(), new Component()]; components.forEach((component) => list.add(component)); list.iterate((component) => { const index = components.indexOf(component); expect(index).not.toBe(-1); components.splice(index, 1); }); expect(components.length).toBe(0); }); it('removing current component during iteration won\'t breaks iteration', () => { const list = new LinkedComponentList(); const components = [new Component(), new Component(), new Component()]; components.forEach((component) => list.add(component)); list.iterate((component) => { list.remove(component); const index = components.indexOf(component); expect(index).not.toBe(-1); components.splice(index, 1); }); expect(components.length).toBe(0); }); it(`"clear" removes all components from the list`, () => { const list = new LinkedComponentList(); list.add(new Component()); list.add(new Component()); list.add(new Component()); list.clear(); expect(list.isEmpty).toBeTruthy(); }); }); ================================================ FILE: tests/unit/query.spec.ts ================================================ import {Engine, Entity, LinkedComponent, Query, QueryBuilder} from '../../src'; class Position { public x: number = 0; public y: number = 0; constructor(x: number = 0, y: number = 0) { this.x = x; this.y = y; } } class View {} class Move {} class Stay {} class Damage extends LinkedComponent {} describe('Query builder', () => { it('Building query', () => { const query = new QueryBuilder() .contains(Position) .contains(View) .build(); expect(query).toBeDefined(); expect(query.entities).toBeDefined(); expect(query.isEmpty).toBeTruthy(); }); it('Expected that built query matches defined pattern', () => { const query = new QueryBuilder() .contains(Position) .contains(View) .build(); const entities = [ new Entity().add(new Position()).add(new View()), new Entity().add(new Position()).add(new View()), ]; query.matchEntities(entities); expect(query.length).toBe(2); }); it(`Expected that adding the same component to the builder twice will use only it only once for construction of predicate `, () => { const builder = new QueryBuilder() .contains(Position) .contains(Position) .contains(View); expect(builder.getComponents().size).toBe(2); }); it(`Expected that adding the same tag to the builder twice will use only it only once for construction of predicate `, () => { const TAG = 1; const builder = new QueryBuilder() .contains(TAG) .contains(TAG); expect(builder.getTags().size).toBe(1); }); it(`Expected that query built with QueryBuilder matches entities with provided conditions`, () => { const TAG = 1; const query = new QueryBuilder().contains(Position, TAG).build(); query.matchEntities([ new Entity().add(new Position()).add(TAG), new Entity(), new Entity().add(new Position()), new Entity().add(TAG), ]); expect(query.length).toBe(1); }); it(`Expected that query built with QueryBuilder matches entities with provided conditions (no components)`, () => { const TAG = 1; const query = new QueryBuilder().contains(TAG).build(); query.matchEntities([ new Entity().add(new Position()).add(TAG), new Entity(), new Entity().add(new Position()), new Entity().add(TAG), ]); expect(query.length).toBe(2); }); it(`Expected that query built with QueryBuilder matches entities with provided conditions (no tags)`, () => { const TAG = 1; const query = new QueryBuilder().contains(Position).build(); query.matchEntities([ new Entity().add(new Position()).add(TAG), new Entity(), new Entity().add(new Position()), new Entity().add(TAG), ]); expect(query.length).toBe(2); }); }); describe('Query matching', () => { const position = new Position(); const view = new View(); const move = new Move(); const stay = new Stay(); function getQuery() { return new QueryBuilder() .contains(Position, View) .build(); } it('Query not matching entity with only position component', () => { const engine = new Engine(); const entity = new Entity().add(position); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.isEmpty).toBeTruthy(); }); it('Query not matching entity with only view component', () => { const engine = new Engine(); const entity = new Entity().add(view); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.isEmpty).toBeTruthy(); }); it('Query matching entity with view and position components', () => { const engine = new Engine(); const entity = new Entity().add(position).add(view); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.isEmpty).toBeFalsy(); expect(query.entities[0]).toBe(entity); }); it(`Expected that 'has' returns true for entity that is in the query`, () => { const targetEntity = new Entity().add(view); const entities = [ new Entity().add(position), targetEntity, new Entity().add(view).add(position), ]; const query = new Query((entity) => entity.has(View)); query.matchEntities(entities); expect(query.has(targetEntity)).toBeTruthy(); }); it('Adding component to entity adding it to query', () => { const engine = new Engine(); const entity = new Entity().add(position); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.isEmpty).toBeTruthy(); entity.add(view); expect(query.entities.length).toBe(1); }); it('Removing component removes entity from query', () => { const engine = new Engine(); const entity = new Entity().add(position).add(view); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.entities.length).toBe(1); expect(query.entities[0]).toBe(entity); entity.remove(View); expect(query.isEmpty).toBeTruthy(); }); it('Removing not matching with query components not removes entity from query', () => { const engine = new Engine(); const entity = new Entity() .add(position) .add(view) .add(move); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.entities.length).toBe(1); expect(query.entities[0]).toBe(entity); entity.remove(Move); expect(query.entities.length).toBe(1); expect(query.entities[0]).toBe(entity); entity.add(stay); expect(query.entities.length).toBe(1); expect(query.entities[0]).toBe(entity); entity.remove(View); expect(query.isEmpty).toBeTruthy(); }); it('Removing entity from engine removes entity from query', () => { const engine = new Engine(); const entity = new Entity().add(position).add(view); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.entities.length).toBe(1); expect(query.entities[0]).toBe(entity); engine.removeEntity(entity); expect(query.isEmpty).toBeTruthy(); }); it('Removing query from engine clears query and not updating it anymore', () => { const engine = new Engine(); const entity = new Entity().add(position).add(view); const query = getQuery(); engine.addQuery(query); engine.addEntity(entity); expect(query.entities).toBeDefined(); expect(query.entities.length).toBe(1); expect(query.entities[0]).toBe(entity); engine.removeQuery(query); expect(query.isEmpty).toBeTruthy(); engine.removeEntity(entity); engine.addEntity(entity); expect(query.isEmpty).toBeTruthy(); }); it('Entity invalidation should add entity to query with custom predicate', () => { const engine = new Engine(); const entity = new Entity().add(new Position(0, 0)); const query = new Query((entity: Entity) => { return entity.has(Position) && entity.get(Position)!.y > 100; }); engine.addQuery(query); engine.addEntity(entity); expect(query.entities.length).toBe(0); entity.get(Position)!.y = 150; entity.invalidate(); expect(query.entities.length).toBe(1); }); it('Entity invalidation should remove entity from query with custom predicate', () => { const engine = new Engine(); const entity = new Entity().add(new Position(0, 0)); const query = new Query((entity: Entity) => { return entity.has(Position) && entity.get(Position)!.y === 0; }); engine.addQuery(query); engine.addEntity(entity); expect(query.entities.length).toBe(1); entity.get(Position)!.y = 150; entity.invalidate(); expect(query.entities.length).toBe(0); }); it('Entity invalidation should add entity to query with custom predicate', () => { const engine = new Engine(); const entity = new Entity().add(new Position(0, 150)); const query = new Query((entity: Entity) => { return entity.has(Position) && entity.get(Position)!.y === 0; }); engine.addQuery(query); engine.addEntity(entity); expect(query.entities.length).toBe(0); entity.get(Position)!.y = 0; entity.invalidate(); expect(query.entities.length).toBe(1); }); it('Removing and adding components to entity should properly update custom query', () => { const engine = new Engine(); const entity = new Entity().add(new Position(0, 0)); const query = new Query((entity: Entity) => { return entity.has(Position) && !entity.has(View); }); engine.addQuery(query); engine.addEntity(entity); expect(query.length).toBe(1); entity.add(new View()); expect(query.length).toBe(0); entity.remove(View); expect(query.length).toBe(1); }); it('Adding and removing entity that not related to query, must not affect it', () => { const engine = new Engine(); const entity1 = new Entity().add(new Position(0, 0)); const entity2 = new Entity(); const query = new Query((entity: Entity) => { return entity.has(Position); }); engine.addQuery(query); engine.addEntity(entity1); expect(query.length).toBe(1); engine.addEntity(entity2); expect(query.length).toBe(1); engine.removeEntity(entity2); expect(query.length).toBe(1); }); it(`countBy returns the number of elements that tested by predicate successfully`, () => { const initialEntitiesAmount = 10; const entitiesWithViewAmount = 4; const query = new Query((entity: Entity) => { return entity.has(Position); }); const entities = []; for (let i = 0; i < initialEntitiesAmount; i++) { const entity = new Entity().add(new Position()); if (i < entitiesWithViewAmount) { entity.add(new View()); } entities.push(entity); } query.matchEntities(entities); expect(query.countBy((entity: Entity) => entity.hasAll(View, Position))).toBe(entitiesWithViewAmount); }); it(`countBy returns zero for empty query`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); expect(query.countBy((entity: Entity) => entity.hasAll(Position))).toBe(0); }); it(`'first' getter returns first element from the query`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); const entities = [new Entity().add(new Position()), new Entity().add(new Position())]; const firstElement = entities[0]; query.matchEntities(entities); expect(query.first).toBe(firstElement); }); it(`'first' getter returns undefined if the query is empty`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); expect(query.first).toBeUndefined(); }); it(`'last' getter returns last element from the query`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); const entities = [new Entity().add(new Position()), new Entity().add(new Position())]; const lastElement = entities[1]; query.matchEntities(entities); expect(query.last).toBe(lastElement); }); it(`'last' getter returns undefined if the query is empty`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); expect(query.last).toBeUndefined(); }); it(`'find' returns first element that is accepted by predicate`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); const entities = [ new Entity().add(new Position()), new Entity().add(new Position()).add(new View()), new Entity().add(new Position()).add(new View()), ]; const targetEntity = entities[1]; query.matchEntities(entities); expect(query.find((value) => value.has(View))).toBe(targetEntity); }); it(`'find' returns undefined when no suitable elements found`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); query.matchEntities([ new Entity().add(new Position()), new Entity().add(new Position()), new Entity().add(new Position()), ]); expect(query.find((value) => value.has(View))).toBeUndefined(); }); it(`'filter' returns all suitable elements`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); const TAG = 'tag'; const entities = [ new Entity().add(new Position()), new Entity().add(new Position()).add(TAG), new Entity().add(new Position()).add(TAG), ]; query.matchEntities(entities); const filteredItems = query.filter((value) => value.has(TAG)); expect(filteredItems.length).toBe(2); expect(filteredItems[0]).toBe(entities[1]); expect(filteredItems[1]).toBe(entities[2]); }); it(`'filter' returns empty array when no suitable elements found`, () => { const query = new Query((entity: Entity) => { return entity.has(Position); }); const TAG = 'tag'; const entities = [ new Entity().add(TAG), new Entity().add(TAG), new Entity().add(TAG), ]; query.matchEntities(entities); const filteredItems = query.filter((value) => value.has(Position)); expect(filteredItems.length).toBe(0); }); it(`appending LinkedComponent affects query`, () => { class LogComponent extends LinkedComponent { public constructor(public readonly log: string) { super(); } } const query = new Query((entity: Entity) => { return entity.has(LogComponent); }); const entity = new Entity() .append(new LogComponent('test')); query.matchEntities([entity]); expect(query.length).toBe(1); }); }); describe('Query signals', () => { it('Query`s onEntityAdded must be invoked when entity is added to query', () => { const query = new Query((entity: Entity) => { return entity.hasAll(View, Position); }); let currentState = undefined; let previousState = undefined; query.onEntityAdded.connect(snapshot => { currentState = snapshot.current.hasAll(View, Position); previousState = snapshot.previous.has(View) && !snapshot.previous.has(Position); }); const entity = new Entity() .add(new View()); query.matchEntities([entity]); entity.add(new Position()); query.entityComponentAdded(entity, entity.get(Position)!, Position); expect(currentState).toBeTruthy(); expect(previousState).toBeTruthy(); }); it('Query`s onEntityRemoved must be invoked when entity is removed from query', () => { const query = new Query((entity: Entity) => { return entity.hasAll(View, Position); }); let currentState = undefined; let previousState = undefined; query.onEntityRemoved.connect(snapshot => { currentState = snapshot.current.has(View) && !snapshot.current.has(Position); previousState = snapshot.previous.hasAll(View, Position); }); const entity = new Entity() .add(new View()) .add(new Position()); query.matchEntities([entity]); query.entityComponentRemoved(entity, entity.remove(Position)!, Position); expect(currentState).toBeTruthy(); expect(previousState).toBeTruthy(); }); it('Query`s onEntityAdded must be invoked when specific component is removed from entity', () => { const query = new Query((entity: Entity) => { return entity.has(View) && !entity.has(Position); }); let currentState = undefined; let previousState = undefined; query.onEntityAdded.connect(snapshot => { currentState = snapshot.current.has(View) && !snapshot.current.has(Position); previousState = snapshot.previous.hasAll(View, Position); }); const entity = new Entity() .add(new View()) .add(new Position()); query.matchEntities([entity]); query.entityComponentRemoved(entity, entity.remove(Position)!, Position); expect(currentState).toBeTruthy(); expect(previousState).toBeTruthy(); }); it('Query`s onEntityRemoved must be invoked when specific component is added to entity', () => { const query = new Query((entity: Entity) => { return entity.has(View) && !entity.has(Position); }); let currentState = undefined; let previousState = undefined; query.onEntityRemoved.connect(snapshot => { currentState = snapshot.current.hasAll(View, Position); previousState = snapshot.previous.has(View) && !snapshot.previous.has(Position); }); const entity = new Entity() .add(new View()); query.matchEntities([entity]); entity.add(new Position()); query.entityComponentAdded(entity, entity.get(Position)!, Position); expect(currentState).toBeTruthy(); expect(previousState).toBeTruthy(); }); it('Query onEntityAdded mustn\'t be triggered more than once if several linked components added to the entity', () => { const query = new Query((entity: Entity) => { return entity.has(Damage); }); let addedNumber = 0; query.onEntityAdded.connect(snapshot => { addedNumber++; }); const entity = new Entity() .append(new Damage()); query.matchEntities([entity]); for (let i = 0; i < 3; i++) { const damage = new Damage(); entity.append(damage); query.entityComponentAdded(entity, damage, Damage); } expect(addedNumber).toBe(1); }); it('Query onEntityRemoved must be triggered only when last linked component withdrawn', () => { const query = new Query((entity: Entity) => { return entity.has(Damage); }); let removedNumber = 0; query.onEntityRemoved.connect(snapshot => { removedNumber++; }); const entity = new Entity() .append(new Damage()) .append(new Damage()) .append(new Damage()); query.matchEntities([entity]); while (entity.has(Damage)) { const damage = entity.withdraw(Damage)!; query.entityComponentRemoved(entity, damage, Damage); } expect(removedNumber).toBe(1); }); it(`Query.entityComponentAdded will be call after all other connected handlers`, () => { const engine = new Engine(); const query = new Query((entity) => entity.has(View)); const entity = new Entity(); engine.addQuery(query); engine.addEntity(entity); let callIndex = 0; let queryCallIndex; entity.onComponentAdded.connect(() => { callIndex++; }); query.onEntityAdded.connect(() => { queryCallIndex = callIndex++; }); entity.add(new View()); expect(queryCallIndex).toBe(1); }); it(`Query.entityComponentRemoved must be called only when all linked components are removed and after all other handlers`, () => { const engine = new Engine(); const query = new Query((entity) => entity.has(Damage)); const entity = new Entity(); engine.addQuery(query); engine.addEntity(entity); entity .append(new Damage()) .append(new Damage()) .append(new Damage()) .append(new Damage()) .append(new Damage()); let callIndex = 0; let queryCallIndex; entity.onComponentRemoved.connect(() => { callIndex++; }, 1000); query.onEntityRemoved.connect(() => { queryCallIndex = callIndex++; }); entity.remove(Damage); expect(queryCallIndex).toBe(5); }); }); ================================================ FILE: tests/unit/shared.config.spec.ts ================================================ import {Engine, Query, System} from '../../src'; describe('Shared config', () => { it('Shared config is accessible when system added to engine', () => { let sharedConfigAccessible = false; const engine = new Engine(); const system = new class extends System { public onAddedToEngine() { sharedConfigAccessible = this.sharedConfig !== undefined; } }(); expect(() => { engine.addSystem(system); }).not.toThrowError(); expect(sharedConfigAccessible).toBeTruthy(); }); it('Accessing shared config throws an error, when system is not added to engine', () => { expect(() => { new class extends System { public constructor() { super(); this.sharedConfig; } }; }).toThrowError(); }); it(`Shared config can't be removed from engine`, () => { expect(() => { class Component {} const engine = new Engine(); engine.sharedConfig.add(new Component()); engine.removeEntity(engine.sharedConfig); let sharedConfigAccessibleAndStillTheSame = false; const system = new class extends System { public onAddedToEngine() { sharedConfigAccessibleAndStillTheSame = this.sharedConfig !== undefined && this.sharedConfig.has(Component); } }; engine.addSystem(system); expect(sharedConfigAccessibleAndStillTheSame).toBeTruthy(); }); }); it(`Shared config is presented in the queries`, () => { expect(() => { const TAG = 'tag'; const engine = new Engine(); const query = new Query((entity) => entity.has(TAG)); engine.sharedConfig.add(TAG); engine.addQuery(query); expect(query.length).toBe(1); expect(query.first).toBe(engine.sharedConfig); }); }); }); ================================================ FILE: tests/unit/signal.spec.ts ================================================ import {Signal} from '../../src/utils/Signal'; describe('Signals', function () { it('Connecting increases amount of handlers', () => { const signal = new Signal<(value: number) => void>(); signal.connect((value: number) => {}); expect(signal.hasHandlers).toBeTruthy(); expect(signal.handlersAmount).toEqual(1); }); it('Connecting same handler twice add it only once', () => { const signal = new Signal<(value: number) => void>(); const handler = (value: number) => {}; signal.connect(handler); signal.connect(handler); expect(signal.handlersAmount).toBe(1); }); it('Disconnecting decreases amount of handlers', () => { const signal = new Signal<(value: number) => void>(); const handler = (value: number) => {}; signal.connect(handler); signal.disconnect(handler); expect(signal.hasHandlers).toBeFalsy(); }); it('Disconnecting not connected handler don\'t remove any existing handler', () => { const signal = new Signal<(value: number) => void>(); const addedHandler = (value: number) => {}; const wrongHandler = (value: number) => {}; signal.connect(addedHandler); signal.disconnect(wrongHandler); expect(signal.handlersAmount).toEqual(1); }); it('Disconnecting all handlers clears them from signal', () => { const signal = new Signal<(value: number) => void>(); signal.connect(() => {}); signal.connect(() => {}); signal.connect(() => {}); signal.disconnectAll(); expect(signal.hasHandlers).toBeFalsy(); }); }); ================================================ FILE: tests/unit/system.spec.ts ================================================ import {Engine, Entity, EntitySnapshot, IterativeSystem, Query, QueryBuilder, System} from '../../src'; class Position { public x: number = 0; public y: number = 0; public constructor(x: number = 0, y: number = 0) { this.x = x; this.y = y; } } class MovementSystem extends IterativeSystem { public constructor() { super(new QueryBuilder().contains(Position).build()); } protected updateEntity(entity: Entity, dt: number): void { const position = entity.get(Position); if (position != null) { position.x += 10 * dt; position.y += 10 * dt; } } protected entityAdded = ({current}: EntitySnapshot) => { current.get(Position)!.x = 100; }; } describe('Iterative system', () => { it('Updating entities', () => { const engine = new Engine(); const entity = new Entity().add(new Position()); engine.addSystem(new MovementSystem()); engine.addEntity(entity); engine.update(1); const position = entity.get(Position); expect(position).toBeDefined(); expect(position!.x).toBe(110); expect(position!.y).toBe(10); }); it('Entities in prepare should be available', () => { let entities!: ReadonlyArray; class TestSystem extends IterativeSystem { public constructor() { super(new QueryBuilder().contains(Position).build()); } protected prepare() { entities = this.entities; } protected updateEntity(entity: Entity, dt: number): void { } } const engine = new Engine(); const entitiesCount = 5; for (let i = 0; i < entitiesCount; i++) { engine.addEntity(new Entity().add(new Position())); } engine.addSystem(new TestSystem()); expect(entities).toBeDefined(); expect(entities.length).toBe(entitiesCount); }); it('Adding and removing should properly construct EntitySnapshot ', () => { let onRemoved: { snapshot?: boolean, entity?: boolean } = {snapshot: undefined, entity: undefined}; let onAdded: { snapshot?: boolean, entity?: boolean } = {snapshot: undefined, entity: undefined}; class MovementSystem extends IterativeSystem { public constructor() { super(new QueryBuilder().contains(Position).build()); } protected updateEntity(entity: Entity, dt: number): void { } protected entityAdded = ({current, previous}: EntitySnapshot) => { onAdded = {snapshot: previous.has(Position), entity: current.has(Position)}; }; protected entityRemoved = ({current, previous}: EntitySnapshot) => { onRemoved = {snapshot: previous.has(Position), entity: current.has(Position)}; }; } const engine = new Engine(); const entity = new Entity(); const system = new MovementSystem(); engine.addSystem(system); engine.addEntity(entity); engine.update(1); entity.add(new Position()); entity.remove(Position); expect(onAdded).toEqual({snapshot: false, entity: true}); expect(onRemoved).toEqual({snapshot: true, entity: false}); }); it("Entities safe removal during iteration should not break the iteration ordering", () => { class Health { public constructor(public value: number) { } } class HealthTickSystem extends IterativeSystem { public constructor() { super(new QueryBuilder().contains(Health).build()); } protected updateEntity(entity: Entity, dt: number): void { const health = entity.get(Health)!; health.value -= 1; if (health.value <= 0) { this.engine.removeEntity(entity, true); } } } const engine = new Engine(); engine.addSystem(new HealthTickSystem()); for (let i = 0; i < 5; i++) { engine.addEntity(new Entity().add(new Health(1))); } engine.update(1); expect(engine.entities.length).toBe(0); }) it.each([true, false])(`Re-adding entities which were removed should work after the engine update cycle`, (safe) => { const engine = new Engine(); const query = new QueryBuilder().contains(Position).build(); engine.addQuery(query); for (let i = 0; i < 5; i++) { engine.addEntity(new Entity().add(new Position())); } const entities = query.entities.concat() for (let entity of entities) { engine.removeEntity(entity, safe); } for (let entity of entities) { engine.addEntity(entity); } engine.update(0); expect(engine.entities.length).toBe(5); }) }); describe('Failure on accessing engine if not attached to it', () => { it(`Expected that engine can't be accessed if system is not attached to it`, () => { class Message { } class TestSystem extends System { public update(dt: number) { this.engine.addEntity(new Entity()); } } const system = new TestSystem(); expect(() => system.update(0)).toThrowError(); }); it(`Expected that message can't be sent if system is not attached to the engine`, () => { class Message { } class TestSystem extends System { public update(dt: number) { this.dispatch(new Message()); } } const system = new TestSystem(); expect(() => system.update(0)).toThrowError(); }); it(`Expected that removing system from engine breaking the iteration`, () => { class Component { } let amountOfIterations = 0; class TestSystem extends IterativeSystem { public constructor() { super(new Query(entity => entity.has(Component))); } protected updateEntity(entity: Entity, dt: number) { // In case if iteration continues - after removing system from engine // then the line below should throw an exception this.engine.clear(); amountOfIterations++; } } const engine = new Engine(); engine.addSystem(new TestSystem()); engine.addEntity(new Entity().add(new Component())); engine.addEntity(new Entity().add(new Component())); engine.addEntity(new Entity().add(new Component())); expect(() => { engine.update(0); }).not.toThrowError(); expect(amountOfIterations).toBe(1); }); it(`Iterative system should iterate over entities after removing and subsequent adding it to the engine`, () => { class Component { } const engine = new Engine(); const entity = new Entity().add(new Component()); let iterationsCount = 0; const system = new class extends IterativeSystem { public constructor() { super((entity) => entity.has(Component)); } protected updateEntity(entity: Entity, dt: number) { iterationsCount++; } }(); engine.addEntity(entity); engine.addSystem(system); engine.update(1); engine.removeSystem(system); engine.update(1); engine.addSystem(system); engine.update(1); expect(iterationsCount).toBe(2); }); it(`After removal request system must be deleted`, () => { const engine = new Engine(); let iterationsCount = 0; const system = new class extends System { public update(dt: number) { iterationsCount++; this.requestRemoval(); } }; engine.addSystem(system); for (let i = 0; i < 5; i++) { engine.update(0); } expect(iterationsCount).toBe(1); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "moduleResolution": "node", "module": "commonjs", "target": "es2016", "lib": [ "es2016" ], "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "declaration": true, "noEmitHelpers": true, "noImplicitThis": true, "alwaysStrict": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictPropertyInitialization": true, "importHelpers": true, "downlevelIteration": false, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "outDir": "lib", "typeRoots": ["node_modules/@types"], "stripInternal": true }, "include": [ "src/**/*" ], "exclude": [ "tests/**/*" ] }