├── .gitignore ├── .npmignore ├── .npmrc ├── .nvmrc ├── .prettierrc ├── LICENSE ├── babel.config.json ├── benchmark.js ├── example ├── components │ ├── Action.js │ ├── EquipmentSlot.js │ ├── Health.js │ ├── Listener.js │ ├── Material.js │ └── Position.js ├── index.js └── prefabs │ ├── BeingPrefab.js │ └── HumanPrefab.js ├── jest.config.json ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── src ├── Component.js ├── ComponentRegistry.js ├── Engine.js ├── Entity.js ├── EntityEvent.js ├── Prefab.js ├── PrefabComponent.js ├── PrefabRegistry.js ├── Query.js ├── QueryManager.js ├── World.js ├── index.js └── util │ ├── bit-util.js │ ├── deep-clone.js │ └── string-util.js └── tests ├── data ├── components.js └── prefabs.js ├── integration ├── EntityClone.spec.js ├── Events.spec.js ├── Query.spec.js └── Serialization.spec.js ├── jest.setup.js └── unit ├── Component.spec.js ├── ComponentRegistry.spec.js ├── Engine.spec.js ├── Entity.spec.js ├── Prefab.spec.js ├── Query.spec.js └── World.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | build 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.5 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dalton Mills 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-proposal-class-properties"], 3 | "env": { 4 | "test": { 5 | "presets": ["@babel/preset-env"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | import { Engine } from './src/Engine'; 2 | import { Component } from './src/Component'; 3 | 4 | class ComponentA extends Component { 5 | static allowMultiple = true; 6 | } 7 | 8 | class ComponentB extends Component { 9 | static allowMultiple = true; 10 | static keyProperty = 'k'; 11 | static properties = { 12 | k: 0, 13 | }; 14 | } 15 | 16 | class ComponentC extends Component {} 17 | 18 | let engine, world; 19 | 20 | let time = 0; 21 | const testCount = 30; 22 | 23 | for (let i = 0; i < testCount; i++) { 24 | engine = new Engine(); 25 | world = engine.createWorld(); 26 | 27 | engine.registerComponent(ComponentA); 28 | engine.registerComponent(ComponentB); 29 | engine.registerComponent(ComponentC); 30 | 31 | world.createQuery({ 32 | all: [ComponentA], 33 | }); 34 | 35 | world.createQuery({ 36 | all: [ComponentB], 37 | }); 38 | 39 | world.createQuery({ 40 | all: [ComponentC], 41 | }); 42 | 43 | const start = Date.now(); 44 | 45 | for (let j = 0; j < 10000; j++) { 46 | const entity = world.createEntity(); 47 | 48 | entity.add(ComponentA); 49 | entity.add(ComponentB); 50 | entity.add(ComponentC); 51 | 52 | entity.destroy(); 53 | } 54 | 55 | const end = Date.now(); 56 | const delta = end - start; 57 | 58 | console.log(`T(${i}) ${Math.round(delta)}ms`); 59 | 60 | time += delta; 61 | } 62 | 63 | const avg = time / testCount; 64 | 65 | console.log(`AVG(${testCount}) ${Math.round(avg)}ms`); 66 | -------------------------------------------------------------------------------- /example/components/Action.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export default class Action extends Component { 4 | static properties = { 5 | name: '', 6 | data: {}, 7 | }; 8 | 9 | static allowMultiple = true; 10 | 11 | onAttached() { 12 | console.log(`action ${this.name} attached`); 13 | } 14 | 15 | onDetached() { 16 | console.log(`action ${this.name} detached`); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/components/EquipmentSlot.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export default class EquipmentSlot extends Component { 4 | static allowMultiple = true; 5 | static keyProperty = 'name'; 6 | static properties = { 7 | name: 'head', 8 | allowedTypes: ['hand'], 9 | content: '', 10 | }; 11 | 12 | onDetached() { 13 | console.log( 14 | `${this.properties.name} slot removed from ${this.properties.entity}` 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/components/Health.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export default class Health extends Component { 4 | static properties = { 5 | max: 10, 6 | current: 10, 7 | }; 8 | 9 | onTest(evt) { 10 | evt.handle(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /example/components/Listener.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export default class Listener extends Component { 4 | static properties = { 5 | range: 6, 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /example/components/Material.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export default class Material extends Component { 4 | static properties = { 5 | name: 'air', 6 | }; 7 | 8 | onAttached() { 9 | console.log(`${this.name} onAttached`, this.entity.id); 10 | } 11 | 12 | onDetached() { 13 | console.log(`${this.name} onDetached`, this.entity.id); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example/components/Position.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export default class Position extends Component { 4 | static properties = { 5 | x: 0, 6 | y: 0, 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import Material from './components/Material'; 2 | import Position from './components/Position'; 3 | import Listener from './components/Listener'; 4 | import Health from './components/Health'; 5 | import Action from './components/Action'; 6 | import BeingPrefab from './prefabs/BeingPrefab'; 7 | import HumanPrefab from './prefabs/HumanPrefab'; 8 | import EquipmentSlot from './components/EquipmentSlot'; 9 | import { Engine } from '../src/index'; 10 | 11 | const ecs = new Engine(); 12 | const world = ecs.createWorld(); 13 | 14 | ecs.registerComponent(EquipmentSlot); 15 | ecs.registerComponent(Material); 16 | ecs.registerComponent(Position); 17 | ecs.registerComponent(Listener); 18 | ecs.registerComponent(Health); 19 | ecs.registerComponent(Action); 20 | 21 | ecs.registerPrefab(BeingPrefab); 22 | ecs.registerPrefab(HumanPrefab); 23 | 24 | const player = world.createEntity(); 25 | const sword = world.createEntity(); 26 | 27 | sword.add(Material, { name: 'bronze' }); 28 | player.add(Position, { x: 4, y: 12 }); 29 | player.add(EquipmentSlot, { 30 | name: 'leftHand', 31 | allowedTypes: ['hand'], 32 | }); 33 | player.add(EquipmentSlot, { 34 | name: 'rightHand', 35 | allowedTypes: ['hand'], 36 | }); 37 | 38 | console.log(player.serialize()); 39 | 40 | const q = world.createQuery({ 41 | all: [Position], 42 | }); 43 | 44 | q.get().forEach((e) => console.log(e.position)); 45 | 46 | // query = ecs.createQuery({ 47 | // all: [Action], // entity must have all of these 48 | // any: [Health, Material], // entity must include at least one of these 49 | // none: [EquipmentSlot] // entity cannot include any of these 50 | // }); 51 | 52 | // console.log(player.get('EquipmentSlot', 'leftHand').allowedTypes); 53 | // console.log(player.equipmentSlot.rightHand.allowedTypes); 54 | // player.equipmentSlot.rightHand.content = sword; 55 | 56 | // const data = ecs.serialize(); 57 | // const human = ecs.createPrefab('HumanPrefab'); 58 | 59 | // ecs2.deserialize(data); 60 | 61 | // const query = ecs.createQuery((entity) => entity.has('Position')); 62 | 63 | // console.log(Object.keys(query.get()).length); 64 | // human.remove('Position'); 65 | // console.log(Object.keys(query.get()).length); 66 | // human.add(Position, { x: 4, y: 12 }); 67 | // console.log(Object.keys(query.get()).length); 68 | 69 | // const thing = ecs.createEntity(); 70 | // thing.add('Position'); 71 | 72 | // console.log(thing.serialize()); 73 | 74 | // const evt = human.fireEvent('test', { some: 'data' }); 75 | 76 | // console.log(evt.data); 77 | // console.log(evt.handled); 78 | 79 | // human.add('Action', { name: 'a' }); 80 | // human.add('Action', { name: 'b' }); 81 | // human.add('Action', { name: 'c' }); 82 | 83 | // human.action[0].remove(); 84 | 85 | // console.log(human.Action); 86 | // console.log(human.has('Action')); 87 | 88 | // human.remove('Action'); 89 | 90 | // console.log(human.has('Action')); 91 | -------------------------------------------------------------------------------- /example/prefabs/BeingPrefab.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'BeingPrefab', 3 | components: [ 4 | { 5 | type: 'Position', 6 | properties: { 7 | x: 4, 8 | y: 10, 9 | }, 10 | }, 11 | { 12 | type: 'Material', 13 | properties: { 14 | name: 'flesh', 15 | }, 16 | }, 17 | 'Listener', 18 | 'Health', 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /example/prefabs/HumanPrefab.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'HumanPrefab', 3 | inherit: 'BeingPrefab', 4 | components: [ 5 | { 6 | type: 'EquipmentSlot', 7 | properties: { 8 | name: 'head', 9 | allowedTypes: ['helmet', 'hat'], 10 | }, 11 | }, 12 | { 13 | type: 'EquipmentSlot', 14 | properties: { 15 | name: 'legs', 16 | allowedTypes: ['pants'], 17 | }, 18 | }, 19 | { 20 | type: 'Material', 21 | overwrite: false, 22 | properties: { 23 | name: 'stuff', 24 | }, 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "setupFilesAfterEnv": ["./tests/jest.setup.js"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geotic", 3 | "version": "4.3.2", 4 | "description": "entity-component-system", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "npm run build && node --enable-source-maps --experimental-modules --es-module-specifier-resolution=node example/index.js", 9 | "build": "rollup -c", 10 | "pretty": "prettier --write .", 11 | "benchmark": "node --experimental-modules --es-module-specifier-resolution=node ./benchmark.js", 12 | "test": "jest --config jest.config.json", 13 | "prepublish": "npm run build" 14 | }, 15 | "author": "Dalton Mills (http://www.ddmills.com)", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/ddmills/geotic/issues" 19 | }, 20 | "homepage": "https://github.com/ddmills/geotic#readme", 21 | "dependencies": { 22 | "camelcase": "6.0.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/cli": "7.17.3", 26 | "@babel/core": "7.9.0", 27 | "@babel/plugin-proposal-class-properties": "7.12.1", 28 | "@babel/preset-env": "7.9.5", 29 | "@rollup/plugin-commonjs": "11.1.0", 30 | "@rollup/plugin-node-resolve": "7.1.3", 31 | "babel-jest": "25.5.0", 32 | "chance": "1.1.4", 33 | "jest": "27.5.1", 34 | "prettier": "2.0.5", 35 | "rollup": "2.7.2", 36 | "rollup-plugin-babel": "4.4.0" 37 | }, 38 | "files": [ 39 | "src", 40 | "build" 41 | ], 42 | "directories": { 43 | "lib": "src" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/ddmills/geotic.git" 48 | }, 49 | "keywords": [ 50 | "Entity", 51 | "Component", 52 | "System", 53 | "ES6", 54 | "Class", 55 | "Javascript", 56 | "Framework", 57 | "Game", 58 | "Engine", 59 | "ecs", 60 | "ec", 61 | "js" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | #### geotic 2 | 3 | _adjective_ physically concerning land or its inhabitants. 4 | 5 | Geotic is an ECS library focused on **performance**, **features**, and **non-intrusive design**. [View benchmarks](https://github.com/ddmills/js-ecs-benchmarks). 6 | 7 | - [**entity**](#entity) a unique id and a collection of components 8 | - [**component**](#component) a data container 9 | - [**query**](#query) a way to gather collections of entities that match some criteria, for use in systems 10 | - [**world**](#world) a container for entities and queries 11 | - [**prefab**](#prefab) a template of components to define entities as JSON 12 | - [**event**](#event) a message to an entity and it's components 13 | 14 | This library is _heavily_ inspired by ECS in _Caves of Qud_. Watch these talks to get inspired! 15 | 16 | - [Thomas Biskup - There be dragons: Entity Component Systems for Roguelikes](https://www.youtube.com/watch?v=fGLJC5UY2o4&t=1534s) 17 | - [Brian Bucklew - AI in Qud and Sproggiwood](https://www.youtube.com/watch?v=4uxN5GqXcaA) 18 | - [Brian Bucklew - Data-Driven Engines of Qud and Sproggiwood](https://www.youtube.com/watch?v=U03XXzcThGU) 19 | 20 | Python user? Check out the Python port of this library, [ecstremity](https://github.com/krummja/ecstremity)! 21 | 22 | ### usage and examples 23 | 24 | ``` 25 | npm install geotic 26 | ``` 27 | 28 | - [Sleepy Crawler](https://github.com/ddmills/sleepy) a full fledged roguelike that makes heavy use of geotic by [@ddmills](https://github.com/ddmills) 29 | - [snail6](https://github.com/luetkemj/snail6) a bloody roguelike by [@luetkemj](https://github.com/luetkemj) 30 | - [Gobs O' Goblins](https://github.com/luetkemj/gobs-o-goblins) by [@luetkemj](https://github.com/luetkemj) 31 | - [Javascript Roguelike Tutorial](https://github.com/luetkemj/jsrlt) by [@luetkemj](https://github.com/luetkemj) 32 | - [basic example](https://github.com/ddmills/geotic-example) using pixijs 33 | 34 | Below is a contrived example which shows the basics of geotic: 35 | 36 | ```js 37 | import { Engine, Component } from 'geotic'; 38 | 39 | // define some simple components 40 | class Position extends Component { 41 | static properties = { 42 | x: 0, 43 | y: 0, 44 | }; 45 | } 46 | 47 | class Velocity extends Component { 48 | static properties = { 49 | x: 0, 50 | y: 0, 51 | }; 52 | } 53 | 54 | class IsFrozen extends Component {} 55 | 56 | const engine = new Engine(); 57 | 58 | // all Components and Prefabs must be `registered` by the engine 59 | engine.registerComponent(Position); 60 | engine.registerComponent(Velocity); 61 | engine.registerComponent(IsFrozen); 62 | 63 | ... 64 | // create a world to hold and create entities and queries 65 | const world = engine.createWorld(); 66 | 67 | // Create an empty entity. Call `entity.id` to get the unique ID. 68 | const entity = world.createEntity(); 69 | 70 | // add some components to the entity 71 | entity.addComponent(Position, { x: 4, y: 10 }); 72 | entity.addComponent(Velocity, { x: 1, y: .25 }); 73 | 74 | // create a query that tracks all components that have both a `Position` 75 | // and `Velocity` component but not a `IsFrozen` component. A query can 76 | // have any combination of `all`, `none` and `any` 77 | const kinematics = world.createQuery({ 78 | all: [Position, Velocity], 79 | none: [IsFrozen] 80 | }); 81 | 82 | ... 83 | 84 | // geotic does not dictate how your game loop should behave 85 | const loop = (dt) => { 86 | // loop over the result set to update the position for all entities 87 | // in the query. The query will always return an up-to-date array 88 | // containing entities that match 89 | kinematics.get().forEach((entity) => { 90 | entity.position.x += entity.velocity.x * dt; 91 | entity.position.y += entity.velocity.y * dt; 92 | }); 93 | }; 94 | 95 | ... 96 | 97 | // serialize all world entities into a JS object 98 | const data = world.serialize(); 99 | 100 | ... 101 | 102 | // convert the serialized data back into entities and components 103 | world.deserialize(data); 104 | 105 | ``` 106 | 107 | ### Engine 108 | 109 | The `Engine` class is used to register all components and prefabs, and create new Worlds. 110 | 111 | ```js 112 | import { Engine } from 'geotic'; 113 | 114 | const engine = new Engine(); 115 | 116 | engine.registerComponent(clazz); 117 | engine.registerPrefab({ ... }); 118 | engine.destroyWorld(world); 119 | ``` 120 | 121 | Engine properties and methods: 122 | 123 | - **registerComponent(clazz)**: register a Component so it can be used by entities 124 | - **regsterPrefab(data)**: register a Prefab to create pre-defined entities 125 | - **destroyWorld(world)**: destroy a world instance 126 | 127 | ### World 128 | 129 | The `World` class is a container for entities. Usually only one instance is needed, 130 | but it can be useful to spin up more for offscreen work. 131 | 132 | ```js 133 | import { Engine } from 'geotic'; 134 | 135 | const engine = new Engine(); 136 | const world = engine.createWorld(); 137 | 138 | // create/destroy entities 139 | world.createEntity(); 140 | world.getEntity(entityId); 141 | world.getEntities(); 142 | world.destroyEntity(entityId); 143 | world.destroyEntities(); 144 | 145 | // create queries 146 | world.createQuery({ ... }); 147 | 148 | // create entity from prefab 149 | world.createPrefab('PrefabName', { ... }); 150 | 151 | // serialize/deserialize entities 152 | world.serialize(); 153 | world.serialize(entities); 154 | world.deserialize(data); 155 | 156 | // create an entity with a new ID and identical components & properties 157 | world.cloneEntity(entity); 158 | 159 | // generate unique entity id 160 | world.createId(); 161 | 162 | // destroy all entities and queries 163 | world.destroy(); 164 | ``` 165 | 166 | World properties and methods: 167 | 168 | - **createEntity(id = null)**: create an `Entity`. optionally provide an ID 169 | - **getEntity(id)**: get an `Entity` by ID 170 | - **getEntities()**: get _all_ entities in this world 171 | - **createPrefab(name, properties = {})**: create an entity from the registered prefab 172 | - **destroyEntity(entity)**: destroys an entity. functionally equivilant to `entity.destroy()` 173 | - **destroyEntities()**: destroys all entities in this world instance 174 | - **serialize(entities = null)**: serialize and return all entity data into an object. optionally specify a list of entities to serialize 175 | - **deserialize(data)**: deserialize an object 176 | - **cloneEntity(entity)**: clone an entity 177 | - **createId()**: Generates a unique ID 178 | - **destroy()**: destroy all entities and queries in the world 179 | 180 | ### Entity 181 | 182 | A unique id and a collection of components. 183 | 184 | ```js 185 | const zombie = world.createEntity(); 186 | 187 | zombie.add(Name, { value: 'Donnie' }); 188 | zombie.add(Position, { x: 2, y: 0, z: 3 }); 189 | zombie.add(Velocity, { x: 0, y: 0, z: 1 }); 190 | zombie.add(Health, { value: 200 }); 191 | zombie.add(Enemy); 192 | 193 | zombie.name.value = 'George'; 194 | zombie.velocity.x += 12; 195 | 196 | zombie.fireEvent('hit', { damage: 12 }); 197 | 198 | if (zombie.health.value <= 0) { 199 | zombie.destroy(); 200 | } 201 | ``` 202 | 203 | Entity properties and methods: 204 | 205 | - **id**: the entities' unique id 206 | - **world**: the geotic World instance 207 | - **isDestroyed**: returns `true` if this entity is destroyed 208 | - **components**: all component instances attached to this entity 209 | - **add(ComponentClazz, props={})**: create and add the registered component to the entity 210 | - **has(ComponentClazz)**: returns true if the entity has component 211 | - **owns(component)**: returns `true` if the specified component belongs to this entity 212 | - **remove(component)**: remove the component from the entity and destroy it 213 | - **destroy()**: destroy the entity and all of it's components 214 | - **serialize()**: serialize this entity and it's components 215 | - **clone()**: returns an new entity with a new unique ID and identical components & properties 216 | - **fireEvent(name, data={})**: send an event to all components on the entity 217 | 218 | ### Component 219 | 220 | Components hold entity data. A component must be defined and then registered with the Engine. This example defines a simple `Health` component: 221 | 222 | ```js 223 | import { Component } from 'geotic'; 224 | 225 | class Health extends Component { 226 | // these props are defaulting to 10 227 | // anything defined here will be serialized 228 | static properties = { 229 | current: 10, 230 | maximum: 10, 231 | }; 232 | 233 | // arbitrary helper methods and properties can be declared on 234 | // components. Note that these will NOT be serialized 235 | get isAlive() { 236 | return this.current > 0; 237 | } 238 | 239 | reduce(amount) { 240 | this.current = Math.max(this.current - amount, 0); 241 | } 242 | 243 | heal(amount) { 244 | this.current = Math.min(this.current + amount, this.maximum); 245 | } 246 | 247 | // This is automatically invoked when a `damage-taken` event is fired 248 | // on the entity: `entity.fireEvent('damage-taken', { damage: 12 })` 249 | // the `camelcase` library is used to map event names to methods 250 | onDamageTaken(evt) { 251 | // event `data` is an arbitray object passed as the second parameter 252 | // to entity.fireEvent(...) 253 | this.reduce(evt.data.damage); 254 | 255 | // handling the event will prevent it from continuing 256 | // to any other components on the entity 257 | evt.handle(); 258 | } 259 | } 260 | ``` 261 | 262 | Component properties and methods: 263 | 264 | - **static properties = {}** object that defines the properties of the component. Properties must be json serializable and de-serializable! 265 | - **static allowMultiple = false** are multiple of this component type allowed? If true, components will either be stored as an object or array on the entity, depending on `keyProperty`. 266 | - **static keyProperty = null** what property should be used as the key for accessing this component. if `allowMultiple` is false, this has no effect. If this property is omitted, it will be stored as an array on the component. 267 | - **entity** returns the Entity this component is attached to 268 | - **world** returns the World this component is in 269 | - **isDestroyed** returns `true` if this component is destroyed 270 | - **serialize()** serialize the component properties 271 | - **destroy()** remove this and destroy this component 272 | - **onAttached()** override this method to add behavior when this component is attached (added) to an entity 273 | - **onDestroyed()** override this method to add behavior when this component is removed & destroyed 274 | - **onEvent(evt)** override this method to capture all events coming to this component 275 | - **on\[EventName\](evt)** add these methods to capture the specific event 276 | 277 | This example shows how `allowMultiple` and `keyProperty` work: 278 | 279 | ```js 280 | class Impulse extends Component { 281 | static properties = { 282 | x: 0, 283 | y: 0, 284 | }; 285 | static allowMultiple = true; 286 | } 287 | 288 | ecs.registerComponent(Impulse); 289 | 290 | ... 291 | 292 | // add multiple `Impulse` components to the player 293 | player.add(Impulse, { x: 3, y: 2 }); 294 | player.add(Impulse, { x: 1, y: 0 }); 295 | player.add(Impulse, { x: 5, y: 6 }); 296 | 297 | ... 298 | 299 | // returns the array of Impulse components 300 | player.impulse; 301 | // returns the Impulse at position `2` 302 | player.impulse[2]; 303 | // returns `true` if the component has an `Impulse` component 304 | player.has(Impulse); 305 | 306 | // the `player.impulse` property is an array 307 | player.impulse.forEach((impulse) => { 308 | console.log(impulse.x, impulse.y); 309 | }); 310 | 311 | // remove and destroy the first impulse 312 | player.impulse[0].destroy(); 313 | 314 | ... 315 | 316 | class EquipmentSlot extends Component { 317 | static properties = { 318 | name: 'hand', 319 | itemId: 0, 320 | }; 321 | static allowMultiple = true; 322 | static keyProperty = 'name'; 323 | 324 | get item() { 325 | return this.world.getEntity(this.itemId); 326 | } 327 | 328 | set item(entity) { 329 | return this.itemId = entity.id; 330 | } 331 | } 332 | 333 | ecs.registerComponent(EquipmentSlot); 334 | 335 | ... 336 | 337 | const player = ecs.createEntity(); 338 | const helmet = ecs.createEntity(); 339 | const sword = ecs.createEntity(); 340 | 341 | // add multiple equipment slot components to the player 342 | player.add(EquipmentSlot, { name: 'rightHand' }); 343 | player.add(EquipmentSlot, { name: 'leftHand', itemId: sword.id }); 344 | player.add(EquipmentSlot, { name: 'head', itemId: helmet.id }); 345 | 346 | ... 347 | 348 | // since the `EquipmentSlot` had a `keyProperty=name`, the `name` 349 | // is used to access them 350 | player.equipmentSlot.head; 351 | player.equipmentSlot.rightHand; 352 | 353 | // this will `destroy` the `sword` entity and automatically 354 | // set the `rightHand.item` property to `null` 355 | player.equipmentSlot.rightHand.item.destroy(); 356 | 357 | // remove and destroy the `rightHand` equipment slot 358 | player.equipmentSlot.rightHand.destroy(); 359 | 360 | ``` 361 | 362 | ### Query 363 | 364 | Queries keep track of sets of entities defined by component types. They are limited to the world they're created in. 365 | 366 | ```js 367 | const query = world.createQuery({ 368 | any: [A, B], // exclude any entity that does not have at least one of A OR B. 369 | all: [C, D], // exclude entities that don't have both C AND D 370 | none: [E, F], // exclude entities that have E OR F 371 | }); 372 | 373 | query.get().forEach((entity) => ...); // loop over the latest set (array) of entites that match 374 | 375 | // alternatively, listen for when an individual entity is created/updated that matches 376 | query.onEntityAdded((entity) => { 377 | console.log('an entity was updated or created that matches the query!', entity); 378 | }); 379 | 380 | query.onEntityRemoved((entity) => { 381 | console.log('an entity was updated or destroyed that previously matched the query!', entity); 382 | }); 383 | ``` 384 | 385 | - **query.get()** get the result array of the query. **This array should not be modified in place**. For performance reasons, the result array that is exposed is the working internal query array. 386 | - **onEntityAdded(fn)** add a callback for when an entity is created or updated to match the query 387 | - **onEntityRemoved(fn)** add a callback for when an entity is removed or updated to no longer match the query 388 | - **has(entity)** returns `true` if the given `entity` is being tracked by the query. Mostly used internally 389 | - **refresh()** re-check all entities to see if they match. Very expensive, and only used internally 390 | 391 | #### Performance enhancement 392 | 393 | Set the `immutableResults` option to `false` if you are not modifying the result set. This option defaults to `true`. **WARNING**: When this option is set to `false`, strange behaviour can occur if you modify the results. See issue #55. 394 | 395 | ```js 396 | const query = world.createQuery({ 397 | all: [A, B], 398 | immutableResult: false, // defaults to TRUE 399 | }); 400 | 401 | const results = query.get(); 402 | 403 | results.splice(0, 1); // DANGER! do not modify results if immutableResult is false! 404 | ``` 405 | 406 | ### serialization 407 | 408 | **example** Save game state by serializing all entities and components 409 | 410 | ```js 411 | const saveGame = () => { 412 | const data = world.serialize(); 413 | localStorage.setItem('savegame', data); 414 | }; 415 | 416 | ... 417 | 418 | const loadGame = () => { 419 | const data = localStorage.getItem('savegame'); 420 | world.deserialize(data); 421 | }; 422 | ``` 423 | 424 | ### Event 425 | 426 | Events are used to send a message to all components on an entity. Components can attach data to the event and prevent it from continuing to other entities. 427 | 428 | The geotic event system is modelled aver this talk by [Brian Bucklew - AI in Qud and Sproggiwood](https://www.youtube.com/watch?v=4uxN5GqXcaA). 429 | 430 | ```js 431 | // a `Health` component which listens for a `take damage` event 432 | class Health extends Component { 433 | ... 434 | // event names are mapped to methods using the `camelcase` library. 435 | onTakeDamage(evt) { 436 | console.log(evt); 437 | this.value -= evt.data.amount; 438 | 439 | // the event gets passed to all components the `entity` unless a component 440 | // invokes `evt.prevent()` or `evt.handle()` 441 | evt.handle(); 442 | } 443 | 444 | // watch ALL events coming to component 445 | onEvent(evt) { 446 | console.log(evt.name); 447 | console.log(evt.is('take-damage')); 448 | } 449 | } 450 | 451 | ... 452 | 453 | entity.add(Health); 454 | 455 | const evt = entity.fireEvent('take-damage', { amount: 12 }); 456 | 457 | console.log(evt.name); // return the name of the event. "take-damage" 458 | console.log(evt.data); // return the arbitrary data object attached. { amount: 12 } 459 | console.log(evt.handled); // was `handle()` called? 460 | console.log(evt.prevented); // was `prevent()` or `handle()` called? 461 | console.log(evt.handle()); // handle and prevent the event from continuing 462 | console.log(evt.prevent()); // prevent the event from continuing without marking `handled` 463 | console.log(evt.is('take-damage')); // simple name check 464 | 465 | ``` 466 | 467 | ### Prefab 468 | 469 | Prefabs are a pre-defined template of components. 470 | 471 | The prefab system is modelled after this talk by [Thomas Biskup - There be dragons: Entity Component Systems for Roguelikes](https://www.youtube.com/watch?v=fGLJC5UY2o4&t=1534s). 472 | 473 | ```js 474 | // prefabs must be registered before they can be instantiated 475 | engine.registerPrefab({ 476 | name: 'Being', 477 | components: [ 478 | { 479 | type: 'Position', 480 | properties: { 481 | x: 4, 482 | y: 10, 483 | }, 484 | }, 485 | { 486 | type: 'Material', 487 | properties: { 488 | name: 'flesh', 489 | }, 490 | }, 491 | ], 492 | }); 493 | 494 | ecs.registerPrefab({ 495 | // name used when creating the prefab 496 | name: 'HumanWarrior', 497 | // an array of other prefabs of which this one derives. Note they must be registered in order. 498 | inherit: ['Being', 'Warrior'], 499 | // an array of components to attach 500 | components: [ 501 | { 502 | // this should be a component constructor name 503 | type: 'EquipmentSlot', 504 | // what properties should be assigned to the component 505 | properties: { 506 | name: 'head', 507 | }, 508 | }, 509 | { 510 | // components that allow multiple can easily be added in 511 | type: 'EquipmentSlot', 512 | properties: { 513 | name: 'legs', 514 | }, 515 | }, 516 | { 517 | type: 'Material', 518 | // if a parent prefab already defines a `Material` component, this flag 519 | // will say how to treat it. Defaults to overwrite=true 520 | overwrite: true, 521 | properties: { 522 | name: 'silver', 523 | }, 524 | }, 525 | ], 526 | }); 527 | 528 | ... 529 | 530 | const warrior1 = world.createPrefab('HumanWarrior'); 531 | 532 | // property overrides can be provided as the second argument 533 | const warrior2 = world.createPrefab('HumanWarrior', { 534 | equipmentSlot: { 535 | head: { 536 | itemId: world.createPrefab('Helmet').id 537 | }, 538 | }, 539 | position: { 540 | x: 12, 541 | y: 24, 542 | }, 543 | }); 544 | ``` 545 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | 5 | export default { 6 | input: 'src/index.js', 7 | output: { 8 | dir: 'build', 9 | name: 'geotic', 10 | format: 'es', 11 | sourcemap: true, 12 | }, 13 | plugins: [babel(), commonjs(), resolve({ browser: true })], 14 | }; 15 | -------------------------------------------------------------------------------- /src/Component.js: -------------------------------------------------------------------------------- 1 | import { deepClone } from './util/deep-clone'; 2 | 3 | export class Component { 4 | static allowMultiple = false; 5 | static keyProperty = null; 6 | static properties = {}; 7 | 8 | get world() { 9 | return this.entity.world; 10 | } 11 | 12 | get allowMultiple() { 13 | return this.constructor.allowMultiple; 14 | } 15 | 16 | get keyProperty() { 17 | return this.constructor.keyProperty; 18 | } 19 | 20 | constructor(properties = {}) { 21 | const intrinsics = deepClone(this.constructor.properties); 22 | 23 | Object.assign(this, intrinsics, properties); 24 | } 25 | 26 | destroy() { 27 | this.entity.remove(this); 28 | } 29 | 30 | _onDestroyed() { 31 | this.onDestroyed(); 32 | delete this.entity; 33 | } 34 | 35 | _onEvent(evt) { 36 | this.onEvent(evt); 37 | 38 | if (typeof this[evt.handlerName] === 'function') { 39 | this[evt.handlerName](evt); 40 | } 41 | } 42 | 43 | _onAttached(entity) { 44 | this.entity = entity; 45 | this.onAttached(entity); 46 | } 47 | 48 | serialize() { 49 | const ob = {}; 50 | 51 | for (const key in this.constructor.properties) { 52 | ob[key] = this[key]; 53 | } 54 | 55 | return deepClone(ob); 56 | } 57 | 58 | onAttached(entity) {} 59 | onDestroyed() {} 60 | onEvent(evt) {} 61 | } 62 | -------------------------------------------------------------------------------- /src/ComponentRegistry.js: -------------------------------------------------------------------------------- 1 | import { camelString } from './util/string-util'; 2 | 3 | export class ComponentRegistry { 4 | _cbit = 0; 5 | _map = {}; 6 | 7 | register(clazz) { 8 | const key = camelString(clazz.name); 9 | 10 | clazz.prototype._ckey = key; 11 | clazz.prototype._cbit = BigInt(++this._cbit); 12 | 13 | this._map[key] = clazz; 14 | } 15 | 16 | get(key) { 17 | return this._map[key]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Engine.js: -------------------------------------------------------------------------------- 1 | import { ComponentRegistry } from './ComponentRegistry'; 2 | import { PrefabRegistry } from './PrefabRegistry'; 3 | import { World } from './World'; 4 | 5 | export class Engine { 6 | _components = new ComponentRegistry(); 7 | _prefabs = new PrefabRegistry(this); 8 | 9 | registerComponent(clazz) { 10 | this._components.register(clazz); 11 | } 12 | 13 | registerPrefab(data) { 14 | this._prefabs.register(data); 15 | } 16 | 17 | createWorld() { 18 | return new World(this); 19 | } 20 | 21 | destroyWorld(world) { 22 | world.destroy(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Entity.js: -------------------------------------------------------------------------------- 1 | import { Component } from './Component'; 2 | import { EntityEvent } from './EntityEvent'; 3 | import { addBit, hasBit, subtractBit } from './util/bit-util'; 4 | 5 | const attachComponent = (entity, component) => { 6 | const key = component._ckey; 7 | 8 | entity[key] = component; 9 | entity.components[key] = component; 10 | }; 11 | 12 | const attachComponentKeyed = (entity, component) => { 13 | const key = component._ckey; 14 | 15 | if (!entity.components[key]) { 16 | entity[key] = {}; 17 | entity.components[key] = {}; 18 | } 19 | 20 | entity[key][component[component.keyProperty]] = component; 21 | entity.components[key][component[component.keyProperty]] = component; 22 | }; 23 | 24 | const attachComponentArray = (entity, component) => { 25 | const key = component._ckey; 26 | 27 | if (!entity.components[key]) { 28 | entity[key] = []; 29 | entity.components[key] = []; 30 | } 31 | 32 | entity[key].push(component); 33 | entity.components[key].push(component); 34 | }; 35 | 36 | const removeComponent = (entity, component) => { 37 | const key = component._ckey; 38 | 39 | delete entity[key]; 40 | delete entity.components[key]; 41 | 42 | entity._cbits = subtractBit(entity._cbits, component._cbit); 43 | entity._candidacy(); 44 | }; 45 | 46 | const removeComponentKeyed = (entity, component) => { 47 | const key = component._ckey; 48 | const keyProp = component[component.keyProperty]; 49 | 50 | delete entity[key][keyProp]; 51 | delete entity.components[key][keyProp]; 52 | 53 | if (Object.keys(entity[key]).length <= 0) { 54 | delete entity[key]; 55 | delete entity.components[key]; 56 | entity._cbits = subtractBit(entity._cbits, component._cbit); 57 | entity._candidacy(); 58 | } 59 | }; 60 | 61 | const removeComponentArray = (entity, component) => { 62 | const key = component._ckey; 63 | const idx = entity[key].indexOf(component); 64 | 65 | entity[key].splice(idx, 1); 66 | entity.components[key].splice(idx, 1); 67 | 68 | if (entity[key].length <= 0) { 69 | delete entity[key]; 70 | delete entity.components[key]; 71 | entity._cbits = subtractBit(entity._cbits, component._cbit); 72 | entity._candidacy(); 73 | } 74 | }; 75 | 76 | const serializeComponent = (component) => { 77 | return component.serialize(); 78 | }; 79 | 80 | const serializeComponentArray = (arr) => { 81 | return arr.map(serializeComponent); 82 | }; 83 | 84 | const serializeComponentKeyed = (ob) => { 85 | const ser = {}; 86 | 87 | for (const k in ob) { 88 | ser[k] = serializeComponent(ob[k]); 89 | } 90 | 91 | return ser; 92 | }; 93 | 94 | export class Entity { 95 | _cbits = 0n; 96 | _qeligible = true; 97 | 98 | constructor(world, id) { 99 | this.world = world; 100 | this.id = id; 101 | this.components = {}; 102 | this.isDestroyed = false; 103 | } 104 | 105 | _candidacy() { 106 | if (this._qeligible) { 107 | this.world._candidate(this); 108 | } 109 | } 110 | 111 | add(clazz, properties) { 112 | const component = new clazz(properties); 113 | 114 | if (component.keyProperty) { 115 | attachComponentKeyed(this, component); 116 | } else if (component.allowMultiple) { 117 | attachComponentArray(this, component); 118 | } else { 119 | attachComponent(this, component); 120 | } 121 | 122 | this._cbits = addBit(this._cbits, component._cbit); 123 | component._onAttached(this); 124 | 125 | this._candidacy(); 126 | } 127 | 128 | has(clazz) { 129 | return hasBit(this._cbits, clazz.prototype._cbit); 130 | } 131 | 132 | remove(component) { 133 | if (component.keyProperty) { 134 | removeComponentKeyed(this, component); 135 | } else if (component.allowMultiple) { 136 | removeComponentArray(this, component); 137 | } else { 138 | removeComponent(this, component); 139 | } 140 | 141 | component._onDestroyed(); 142 | } 143 | 144 | destroy() { 145 | for (const k in this.components) { 146 | const v = this.components[k]; 147 | 148 | if (v instanceof Component) { 149 | this._cbits = subtractBit(this._cbits, v._cbit); 150 | v._onDestroyed(); 151 | } else if (v instanceof Array) { 152 | for (const component of v) { 153 | this._cbits = subtractBit(this._cbits, component._cbit); 154 | component._onDestroyed(); 155 | } 156 | } else { 157 | for (const component of Object.values(v)) { 158 | this._cbits = subtractBit(this._cbits, component._cbit); 159 | component._onDestroyed(); 160 | } 161 | } 162 | 163 | delete this[k]; 164 | delete this.components[k]; 165 | } 166 | 167 | this._candidacy(); 168 | this.world._destroyed(this.id); 169 | this.components = {}; 170 | this.isDestroyed = true; 171 | } 172 | 173 | serialize() { 174 | const components = {}; 175 | 176 | for (const k in this.components) { 177 | const v = this.components[k]; 178 | 179 | if (v instanceof Component) { 180 | components[k] = serializeComponent(v); 181 | } else if (v instanceof Array) { 182 | components[k] = serializeComponentArray(v); 183 | } else { 184 | components[k] = serializeComponentKeyed(v); 185 | } 186 | } 187 | 188 | return { 189 | id: this.id, 190 | ...components, 191 | }; 192 | } 193 | 194 | clone() { 195 | return this.world.cloneEntity(this); 196 | } 197 | 198 | fireEvent(name, data) { 199 | const evt = new EntityEvent(name, data); 200 | 201 | for (const key in this.components) { 202 | const v = this.components[key]; 203 | 204 | if (v instanceof Component) { 205 | v._onEvent(evt); 206 | 207 | if (evt.prevented) { 208 | return evt; 209 | } 210 | } else if (v instanceof Array) { 211 | for (let i = 0; i < v.length; i++) { 212 | v[i]._onEvent(evt); 213 | 214 | if (evt.prevented) { 215 | return evt; 216 | } 217 | } 218 | } else { 219 | for (const component of Object.values(v)) { 220 | component._onEvent(evt); 221 | 222 | if (evt.prevented) { 223 | return evt; 224 | } 225 | } 226 | } 227 | } 228 | 229 | return evt; 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/EntityEvent.js: -------------------------------------------------------------------------------- 1 | import { camelString } from './util/string-util'; 2 | 3 | export class EntityEvent { 4 | data = {}; 5 | prevented = false; 6 | handled = false; 7 | 8 | constructor(name, data = {}) { 9 | this.name = name; 10 | this.data = data; 11 | this.handlerName = camelString(`on ${this.name}`); 12 | } 13 | 14 | is(name) { 15 | return this.name === name; 16 | } 17 | 18 | handle() { 19 | this.handled = true; 20 | this.prevented = true; 21 | } 22 | 23 | prevent() { 24 | this.prevented = true; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Prefab.js: -------------------------------------------------------------------------------- 1 | export default class Prefab { 2 | name = ''; 3 | inherit = []; 4 | components = []; 5 | 6 | constructor(name) { 7 | this.name = name; 8 | } 9 | 10 | addComponent(prefabComponent) { 11 | this.components.push(prefabComponent); 12 | } 13 | 14 | applyToEntity(entity, prefabProps = {}) { 15 | this.inherit.forEach((parent) => { 16 | parent.applyToEntity(entity, prefabProps); 17 | }); 18 | 19 | const arrComps = {}; 20 | 21 | this.components.forEach((component) => { 22 | const clazz = component.clazz; 23 | const ckey = clazz.prototype._ckey; 24 | 25 | let initialCompProps = {}; 26 | 27 | if (clazz.allowMultiple) { 28 | if (clazz.keyProperty) { 29 | const key = component.properties[clazz.keyProperty]; 30 | 31 | if (prefabProps[ckey] && prefabProps[ckey][key]) { 32 | initialCompProps = prefabProps[ckey][key]; 33 | } 34 | } else { 35 | if (!arrComps[ckey]) { 36 | arrComps[ckey] = 0; 37 | } 38 | 39 | if ( 40 | prefabProps[ckey] && 41 | prefabProps[ckey][arrComps[ckey]] 42 | ) { 43 | initialCompProps = prefabProps[ckey][arrComps[ckey]]; 44 | } 45 | 46 | arrComps[ckey]++; 47 | } 48 | } else { 49 | initialCompProps = prefabProps[ckey]; 50 | } 51 | 52 | component.applyToEntity(entity, initialCompProps); 53 | }); 54 | 55 | return entity; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/PrefabComponent.js: -------------------------------------------------------------------------------- 1 | import merge from 'deepmerge'; 2 | 3 | export default class PrefabComponent { 4 | constructor(clazz, properties = {}, overwrite = true) { 5 | this.clazz = clazz; 6 | this.properties = properties; 7 | this.overwrite = overwrite; 8 | } 9 | 10 | applyToEntity(entity, initialProps = {}) { 11 | if (!this.clazz.allowMultiple && entity.has(this.clazz)) { 12 | if (!this.overwrite) { 13 | return; 14 | } 15 | 16 | const comp = entity[this.clazz.prototype._ckey]; 17 | 18 | entity.remove(comp); 19 | } 20 | 21 | const props = merge(this.properties, initialProps); 22 | 23 | entity.add(this.clazz, props); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/PrefabRegistry.js: -------------------------------------------------------------------------------- 1 | import PrefabComponent from './PrefabComponent'; 2 | import Prefab from './Prefab'; 3 | import { camelString } from './util/string-util'; 4 | 5 | export class PrefabRegistry { 6 | _prefabs = {}; 7 | _engine = null; 8 | 9 | constructor(engine) { 10 | this._engine = engine; 11 | } 12 | 13 | deserialize(data) { 14 | const registered = this.get(data.name); 15 | 16 | if (registered) { 17 | return registered; 18 | } 19 | 20 | const prefab = new Prefab(data.name); 21 | 22 | let inherit; 23 | 24 | if (Array.isArray(data.inherit)) { 25 | inherit = data.inherit; 26 | } else if (typeof data.inherit === 'string') { 27 | inherit = [data.inherit]; 28 | } else { 29 | inherit = []; 30 | } 31 | 32 | prefab.inherit = inherit.map((parent) => { 33 | const ref = this.get(parent); 34 | 35 | if (!ref) { 36 | console.warn( 37 | `Prefab "${data.name}" cannot inherit from Prefab "${parent}" because is not registered yet! Prefabs must be registered in the right order.` 38 | ); 39 | return parent; 40 | } 41 | 42 | return ref; 43 | }); 44 | 45 | const comps = data.components || []; 46 | 47 | comps.forEach((componentData) => { 48 | let componentName = 'unknown'; 49 | 50 | if (typeof componentData === 'string') { 51 | componentName = componentData; 52 | 53 | const ckey = camelString(componentData); 54 | const clazz = this._engine._components.get(ckey); 55 | 56 | if (clazz) { 57 | prefab.addComponent(new PrefabComponent(clazz)); 58 | 59 | return; 60 | } 61 | } 62 | 63 | if (typeof componentData === 'object') { 64 | componentName = componentData.type || 'unknown'; 65 | 66 | const ckey = camelString(componentData.type); 67 | const clazz = this._engine._components.get(ckey); 68 | 69 | if (clazz) { 70 | prefab.addComponent( 71 | new PrefabComponent( 72 | clazz, 73 | componentData.properties, 74 | componentData.overwrite 75 | ) 76 | ); 77 | 78 | return; 79 | } 80 | } 81 | 82 | console.warn( 83 | `Unrecognized component reference "${componentName}" in prefab "${data.name}". Ensure the component is registered before the prefab.`, 84 | componentData 85 | ); 86 | }); 87 | 88 | return prefab; 89 | } 90 | 91 | register(data) { 92 | const prefab = this.deserialize(data); 93 | 94 | this._prefabs[prefab.name] = prefab; 95 | } 96 | 97 | get(name) { 98 | return this._prefabs[name]; 99 | } 100 | 101 | create(world, name, properties = {}) { 102 | const prefab = this.get(name); 103 | 104 | if (!prefab) { 105 | console.warn( 106 | `Could not instantiate prefab "${name}" since it is not registered` 107 | ); 108 | 109 | return; 110 | } 111 | 112 | const entity = world.createEntity(); 113 | 114 | entity._qeligible = false; 115 | 116 | prefab.applyToEntity(entity, properties); 117 | 118 | entity._qeligible = true; 119 | entity._candidacy(); 120 | 121 | return entity; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Query.js: -------------------------------------------------------------------------------- 1 | import { addBit, bitIntersection } from './util/bit-util'; 2 | 3 | export class Query { 4 | _cache = []; 5 | _onAddListeners = []; 6 | _onRemoveListeners = []; 7 | _immutableResult = true; 8 | 9 | constructor(world, filters) { 10 | this._world = world; 11 | 12 | const any = filters.any || []; 13 | const all = filters.all || []; 14 | const none = filters.none || []; 15 | 16 | this._any = any.reduce((s, c) => { 17 | return addBit(s, c.prototype._cbit); 18 | }, 0n); 19 | 20 | this._all = all.reduce((s, c) => { 21 | return addBit(s, c.prototype._cbit); 22 | }, 0n); 23 | 24 | this._none = none.reduce((s, c) => { 25 | return addBit(s, c.prototype._cbit); 26 | }, 0n); 27 | 28 | this._immutableResult = 29 | filters.immutableResult == undefined 30 | ? true 31 | : filters.immutableResult; 32 | 33 | this.refresh(); 34 | } 35 | 36 | onEntityAdded(fn) { 37 | this._onAddListeners.push(fn); 38 | } 39 | 40 | onEntityRemoved(fn) { 41 | this._onRemoveListeners.push(fn); 42 | } 43 | 44 | has(entity) { 45 | return this.idx(entity) >= 0; 46 | } 47 | 48 | idx(entity) { 49 | return this._cache.indexOf(entity); 50 | } 51 | 52 | matches(entity) { 53 | const bits = entity._cbits; 54 | 55 | const any = this._any === 0n || bitIntersection(bits, this._any) > 0; 56 | const all = bitIntersection(bits, this._all) === this._all; 57 | const none = bitIntersection(bits, this._none) === 0n; 58 | 59 | return any && all && none; 60 | } 61 | 62 | candidate(entity) { 63 | const idx = this.idx(entity); 64 | const isTracking = idx >= 0; 65 | 66 | if (!entity.isDestroyed && this.matches(entity)) { 67 | if (!isTracking) { 68 | this._cache.push(entity); 69 | this._onAddListeners.forEach((cb) => cb(entity)); 70 | } 71 | 72 | return true; 73 | } 74 | 75 | if (isTracking) { 76 | this._cache.splice(idx, 1); 77 | this._onRemoveListeners.forEach((cb) => cb(entity)); 78 | } 79 | 80 | return false; 81 | } 82 | 83 | refresh() { 84 | this._cache = []; 85 | this._world._entities.forEach((entity) => { 86 | this.candidate(entity); 87 | }); 88 | } 89 | 90 | get() { 91 | return this._immutableResult ? [...this._cache] : this._cache; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/QueryManager.js: -------------------------------------------------------------------------------- 1 | export class QueryManager { 2 | _queries = []; 3 | 4 | constructor(world) { 5 | this._world = world; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/World.js: -------------------------------------------------------------------------------- 1 | import { Entity } from './Entity'; 2 | import { Query } from './Query'; 3 | import { camelString } from './util/string-util'; 4 | 5 | export class World { 6 | _id = 0; 7 | _queries = []; 8 | _entities = new Map(); 9 | 10 | constructor(engine) { 11 | this.engine = engine; 12 | } 13 | 14 | createId() { 15 | return ++this._id + Math.random().toString(36).substr(2, 9); 16 | } 17 | 18 | getEntity(id) { 19 | return this._entities.get(id); 20 | } 21 | 22 | getEntities() { 23 | return this._entities.values(); 24 | } 25 | 26 | createEntity(id = this.createId()) { 27 | const entity = new Entity(this, id); 28 | 29 | this._entities.set(id, entity); 30 | 31 | return entity; 32 | } 33 | 34 | destroyEntity(id) { 35 | const entity = this.getEntity(id); 36 | 37 | if (entity) { 38 | entity.destroy(); 39 | } 40 | } 41 | 42 | destroyEntities() { 43 | this._entities.forEach((entity) => { 44 | entity.destroy(); 45 | }); 46 | } 47 | 48 | destroy() { 49 | this.destroyEntities(); 50 | this._id = 0; 51 | this._queries = []; 52 | this._entities = new Map(); 53 | } 54 | 55 | createQuery(filters) { 56 | const query = new Query(this, filters); 57 | 58 | this._queries.push(query); 59 | 60 | return query; 61 | } 62 | 63 | createPrefab(name, properties = {}) { 64 | return this.engine._prefabs.create(this, name, properties); 65 | } 66 | 67 | serialize(entities) { 68 | const json = []; 69 | const list = entities || this._entities; 70 | 71 | list.forEach((e) => { 72 | json.push(e.serialize()); 73 | }); 74 | 75 | return { 76 | entities: json, 77 | }; 78 | } 79 | 80 | cloneEntity(entity) { 81 | const data = entity.serialize(); 82 | 83 | data.id = this.createId(); 84 | 85 | return this._deserializeEntity(data); 86 | } 87 | 88 | deserialize(data) { 89 | for (const entityData of data.entities) { 90 | this._createOrGetEntityById(entityData.id); 91 | } 92 | 93 | for (const entityData of data.entities) { 94 | this._deserializeEntity(entityData); 95 | } 96 | } 97 | 98 | _createOrGetEntityById(id) { 99 | return this.getEntity(id) || this.createEntity(id); 100 | } 101 | 102 | _deserializeEntity(data) { 103 | const { id, ...components } = data; 104 | const entity = this._createOrGetEntityById(id); 105 | entity._qeligible = false; 106 | 107 | Object.entries(components).forEach(([key, value]) => { 108 | const type = camelString(key); 109 | const def = this.engine._components.get(type); 110 | 111 | if (def.allowMultiple) { 112 | Object.values(value).forEach((d) => { 113 | entity.add(def, d); 114 | }); 115 | } else { 116 | entity.add(def, value); 117 | } 118 | }); 119 | 120 | entity._qeligible = true; 121 | entity._candidacy(); 122 | 123 | return entity; 124 | } 125 | 126 | _candidate(entity) { 127 | this._queries.forEach((q) => q.candidate(entity)); 128 | } 129 | 130 | _destroyed(id) { 131 | return this._entities.delete(id); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { Engine } from './Engine'; 2 | export { Component } from './Component'; 3 | -------------------------------------------------------------------------------- /src/util/bit-util.js: -------------------------------------------------------------------------------- 1 | const ONE = 1n; 2 | 3 | export const subtractBit = (num, bit) => { 4 | return num & ~(1n << bit); 5 | }; 6 | 7 | export const addBit = (num, bit) => { 8 | return num | (ONE << bit); 9 | }; 10 | 11 | export const hasBit = (num, bit) => { 12 | return (num >> bit) % 2n !== 0n; 13 | }; 14 | 15 | export const bitIntersection = (n1, n2) => { 16 | return n1 & n2; 17 | }; 18 | -------------------------------------------------------------------------------- /src/util/deep-clone.js: -------------------------------------------------------------------------------- 1 | export const deepClone = (ob) => JSON.parse(JSON.stringify(ob)); 2 | -------------------------------------------------------------------------------- /src/util/string-util.js: -------------------------------------------------------------------------------- 1 | import camelcaseSlow from 'camelcase'; 2 | 3 | const camelCache = {}; 4 | 5 | export const camelString = (value) => { 6 | const result = camelCache[value]; 7 | 8 | if (!result) { 9 | camelCache[value] = camelcaseSlow(value); 10 | 11 | return camelCache[value]; 12 | } 13 | 14 | return result; 15 | }; 16 | -------------------------------------------------------------------------------- /tests/data/components.js: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/index'; 2 | 3 | export class EmptyComponent extends Component {} 4 | 5 | export class SimpleComponent extends Component { 6 | static properties = { 7 | testProp: 'thing', 8 | }; 9 | } 10 | 11 | export class NestedComponent extends Component { 12 | static allowMultiple = true; 13 | static keyProperty = 'name'; 14 | static properties = { 15 | name: 'test', 16 | hello: 'world', 17 | obprop: { 18 | key: 'value', 19 | arr: [1, 2, 3], 20 | }, 21 | }; 22 | } 23 | 24 | export class ArrayComponent extends Component { 25 | static allowMultiple = true; 26 | static properties = { 27 | name: 'a', 28 | hello: 'world', 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /tests/data/prefabs.js: -------------------------------------------------------------------------------- 1 | export const EmptyPrefab = { 2 | name: 'EmptyPrefab', 3 | }; 4 | 5 | export const SimplePrefab = { 6 | name: 'SimplePrefab', 7 | components: [ 8 | { 9 | type: 'EmptyComponent', 10 | }, 11 | { 12 | type: 'SimpleComponent', 13 | properties: { 14 | testProp: 'testPropValue', 15 | }, 16 | }, 17 | ], 18 | }; 19 | 20 | export const PrefabWithKeyedAndArray = { 21 | name: 'PrefabWithKeyedAndArray', 22 | components: [ 23 | { 24 | type: 'NestedComponent', 25 | properties: { 26 | name: 'one', 27 | }, 28 | }, 29 | { 30 | type: 'NestedComponent', 31 | properties: { 32 | name: 'two', 33 | }, 34 | }, 35 | { 36 | type: 'ArrayComponent', 37 | properties: { 38 | name: 'a', 39 | }, 40 | }, 41 | { 42 | type: 'ArrayComponent', 43 | properties: { 44 | name: 'b', 45 | }, 46 | }, 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /tests/integration/EntityClone.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/Engine'; 2 | import { 3 | EmptyComponent, 4 | SimpleComponent, 5 | NestedComponent, 6 | ArrayComponent, 7 | } from '../data/components'; 8 | 9 | describe('Entity Clone', () => { 10 | let world; 11 | 12 | beforeEach(() => { 13 | const engine = new Engine(); 14 | 15 | engine.registerComponent(EmptyComponent); 16 | engine.registerComponent(SimpleComponent); 17 | engine.registerComponent(NestedComponent); 18 | engine.registerComponent(ArrayComponent); 19 | 20 | world = engine.createWorld(); 21 | }); 22 | 23 | describe('clone', () => { 24 | let entity, nestedKeyA, nestedKeyB; 25 | 26 | beforeEach(() => { 27 | nestedKeyA = chance.word(); 28 | nestedKeyB = chance.word(); 29 | 30 | entity = world.createEntity(); 31 | entity.add(EmptyComponent); 32 | entity.add(SimpleComponent, { testProp: chance.string() }); 33 | entity.add(ArrayComponent, { name: chance.word() }); 34 | entity.add(ArrayComponent, { name: chance.word() }); 35 | entity.add(NestedComponent, { name: nestedKeyA }); 36 | entity.add(NestedComponent, { name: nestedKeyB }); 37 | }); 38 | 39 | it('should clone component data and assign new id', () => { 40 | const clone = entity.clone(); 41 | 42 | expect(clone.id).not.toEqual(entity.id); 43 | 44 | const entityJson = entity.serialize(); 45 | const cloneJson = clone.serialize(); 46 | 47 | expect({ 48 | ...entityJson, 49 | id: null, 50 | }).toEqual({ 51 | ...cloneJson, 52 | id: null, 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/integration/Events.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine, Component } from '../../src/index'; 2 | import { EmptyComponent } from '../data/components'; 3 | 4 | describe('Events', () => { 5 | let world, onTestEventStub; 6 | 7 | class EventComponent extends Component { 8 | onTestEvent(evt) { 9 | onTestEventStub(evt); 10 | } 11 | } 12 | 13 | beforeEach(() => { 14 | const engine = new Engine(); 15 | 16 | world = engine.createWorld(); 17 | 18 | onTestEventStub = jest.fn(); 19 | engine.registerComponent(EventComponent); 20 | engine.registerComponent(EmptyComponent); 21 | }); 22 | 23 | describe('events', () => { 24 | let entity, data; 25 | 26 | beforeEach(() => { 27 | entity = world.createEntity(); 28 | entity.add(EventComponent); 29 | data = chance.object(); 30 | 31 | entity.fireEvent('test-event', data); 32 | }); 33 | 34 | it('should call the onTestEvent on the component', () => { 35 | expect(onTestEventStub).toHaveBeenCalledTimes(1); 36 | 37 | const arg = onTestEventStub.mock.calls[0][0]; 38 | 39 | expect(arg.name).toBe('test-event'); 40 | expect(arg.data).toBe(data); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/integration/Query.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine, Component } from '../../src/index'; 2 | 3 | describe('Query', () => { 4 | let world, entityA, entityB, result, query, onAddCallback, onRemoveCallback; 5 | 6 | class ComponentA extends Component {} 7 | class ComponentB extends Component {} 8 | class ComponentC extends Component {} 9 | 10 | beforeEach(() => { 11 | const engine = new Engine(); 12 | 13 | engine.registerComponent(ComponentA); 14 | engine.registerComponent(ComponentB); 15 | engine.registerComponent(ComponentC); 16 | 17 | world = engine.createWorld(); 18 | 19 | entityA = world.createEntity(); 20 | entityB = world.createEntity(); 21 | 22 | onAddCallback = jest.fn(); 23 | onRemoveCallback = jest.fn(); 24 | }); 25 | 26 | describe('when there are no matching entities', () => { 27 | beforeEach(() => { 28 | query = world.createQuery({ 29 | any: [ComponentA], 30 | }); 31 | 32 | query.onEntityAdded(onAddCallback); 33 | query.onEntityRemoved(onRemoveCallback); 34 | }); 35 | 36 | it('should return an empty set', () => { 37 | result = query.get(); 38 | 39 | expect([...result]).toStrictEqual([]); 40 | }); 41 | 42 | it('should not invoke any callbacks', () => { 43 | expect(onAddCallback).toHaveBeenCalledTimes(0); 44 | expect(onRemoveCallback).toHaveBeenCalledTimes(0); 45 | }); 46 | 47 | describe('when an entity is created that matches', () => { 48 | let newEntity; 49 | 50 | beforeEach(() => { 51 | newEntity = world.createEntity(); 52 | 53 | newEntity.add(ComponentA); 54 | }); 55 | 56 | it('should be included in result set', () => { 57 | result = query.get(); 58 | 59 | expect([...result]).toStrictEqual([newEntity]); 60 | }); 61 | 62 | it('should invoke the onAddCallback with the new entity', () => { 63 | expect(onAddCallback).toHaveBeenCalledTimes(1); 64 | expect(onAddCallback).toHaveBeenCalledWith(newEntity); 65 | expect(onRemoveCallback).toHaveBeenCalledTimes(0); 66 | }); 67 | }); 68 | 69 | describe('when an entity is edited to match', () => { 70 | beforeEach(() => { 71 | entityA.add(ComponentA); 72 | }); 73 | 74 | it('should be included in result set', () => { 75 | result = query.get(); 76 | 77 | expect([...result]).toStrictEqual([entityA]); 78 | }); 79 | 80 | it('should invoke the onAddCallback with the matching entity', () => { 81 | expect(onAddCallback).toHaveBeenCalledTimes(1); 82 | expect(onAddCallback).toHaveBeenCalledWith(entityA); 83 | expect(onRemoveCallback).toHaveBeenCalledTimes(0); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('when there are matching entities', () => { 89 | beforeEach(() => { 90 | entityA.add(ComponentA); 91 | entityB.add(ComponentA); 92 | 93 | query = world.createQuery({ 94 | any: [ComponentA], 95 | }); 96 | 97 | query.onEntityAdded(onAddCallback); 98 | query.onEntityRemoved(onRemoveCallback); 99 | }); 100 | 101 | it('should return a set including the entities', () => { 102 | result = query.get(); 103 | 104 | expect([...result]).toStrictEqual([entityA, entityB]); 105 | }); 106 | 107 | describe('when an entity is edited to no longer match', () => { 108 | beforeEach(() => { 109 | entityA.componentA.destroy(); 110 | }); 111 | 112 | it('should not be included in result set', () => { 113 | result = query.get(); 114 | 115 | expect([...result]).toStrictEqual([entityB]); 116 | }); 117 | 118 | it('should invoke the onRemoveCallback with the removed entity', () => { 119 | expect(onAddCallback).toHaveBeenCalledTimes(0); 120 | expect(onRemoveCallback).toHaveBeenCalledTimes(1); 121 | expect(onRemoveCallback).toHaveBeenCalledWith(entityA); 122 | }); 123 | }); 124 | 125 | describe('when an entity is destroyed', () => { 126 | beforeEach(() => { 127 | entityA.destroy(); 128 | }); 129 | 130 | it('should not be included in result set', () => { 131 | result = query.get(); 132 | 133 | expect([...result]).toStrictEqual([entityB]); 134 | }); 135 | 136 | it('should invoke the onRemoveCallback with the destroyed entity', () => { 137 | expect(onAddCallback).toHaveBeenCalledTimes(0); 138 | expect(onRemoveCallback).toHaveBeenCalledTimes(1); 139 | expect(onRemoveCallback).toHaveBeenCalledWith(entityA); 140 | }); 141 | }); 142 | }); 143 | 144 | describe('immutableResult', () => { 145 | describe('when immutableResult is true', () => { 146 | beforeEach(() => { 147 | entityA.add(ComponentA); 148 | entityB.add(ComponentA); 149 | 150 | query = world.createQuery({ 151 | any: [ComponentA], 152 | immutableResult: true, 153 | }); 154 | }); 155 | 156 | it('should not modify the query cache when results are modified', () => { 157 | result = query.get(); 158 | 159 | expect(query.get()).toHaveLength(2); 160 | 161 | result.splice(0, 1); 162 | 163 | expect(query.get()).toHaveLength(2); 164 | }); 165 | }); 166 | 167 | describe('when immutableResult is false', () => { 168 | beforeEach(() => { 169 | entityA.add(ComponentA); 170 | entityB.add(ComponentA); 171 | 172 | query = world.createQuery({ 173 | any: [ComponentA], 174 | immutableResult: false, 175 | }); 176 | }); 177 | 178 | it('should modify the query cache when results are modified', () => { 179 | result = query.get(); 180 | 181 | expect(query.get()).toHaveLength(2); 182 | 183 | result.splice(0, 1); 184 | 185 | expect(query.get()).toHaveLength(1); 186 | }); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /tests/integration/Serialization.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/Engine'; 2 | import { 3 | EmptyComponent, 4 | SimpleComponent, 5 | NestedComponent, 6 | ArrayComponent, 7 | } from '../data/components'; 8 | 9 | describe('Serialization', () => { 10 | let world; 11 | 12 | beforeEach(() => { 13 | const engine = new Engine(); 14 | 15 | engine.registerComponent(EmptyComponent); 16 | engine.registerComponent(SimpleComponent); 17 | engine.registerComponent(NestedComponent); 18 | engine.registerComponent(ArrayComponent); 19 | 20 | world = engine.createWorld(); 21 | }); 22 | 23 | describe('serializing', () => { 24 | let entity, json, nestedKeyA, nestedKeyB; 25 | 26 | beforeEach(() => { 27 | nestedKeyA = chance.word(); 28 | nestedKeyB = chance.word(); 29 | 30 | entity = world.createEntity(); 31 | entity.add(EmptyComponent); 32 | entity.add(SimpleComponent, { testProp: chance.string() }); 33 | entity.add(ArrayComponent, { name: chance.word() }); 34 | entity.add(ArrayComponent, { name: chance.word() }); 35 | entity.add(NestedComponent, { name: nestedKeyA }); 36 | entity.add(NestedComponent, { name: nestedKeyB }); 37 | }); 38 | 39 | describe('when no entities are specified', () => { 40 | beforeEach(() => { 41 | json = world.serialize(); 42 | }); 43 | 44 | it('should save all entities and component data', () => { 45 | expect(json).toEqual({ 46 | entities: [ 47 | { 48 | id: entity.id, 49 | emptyComponent: {}, 50 | simpleComponent: { 51 | testProp: entity.simpleComponent.testProp, 52 | }, 53 | arrayComponent: [ 54 | { 55 | name: entity.arrayComponent[0].name, 56 | hello: 'world', 57 | }, 58 | { 59 | name: entity.arrayComponent[1].name, 60 | hello: 'world', 61 | }, 62 | ], 63 | nestedComponent: { 64 | [nestedKeyA]: { 65 | name: nestedKeyA, 66 | hello: 'world', 67 | }, 68 | [nestedKeyB]: { 69 | name: nestedKeyB, 70 | hello: 'world', 71 | }, 72 | }, 73 | }, 74 | ], 75 | }); 76 | }); 77 | }); 78 | 79 | describe('when a list of entities is specified', () => { 80 | let otherEntity; 81 | 82 | beforeEach(() => { 83 | otherEntity = world.createEntity(); 84 | 85 | json = world.serialize([otherEntity, entity]); 86 | }); 87 | 88 | it('should keep all refs', () => { 89 | expect(json).toEqual({ 90 | entities: [ 91 | { id: otherEntity.id }, 92 | { 93 | id: entity.id, 94 | emptyComponent: {}, 95 | simpleComponent: { 96 | testProp: entity.simpleComponent.testProp, 97 | }, 98 | arrayComponent: [ 99 | { 100 | name: entity.arrayComponent[0].name, 101 | hello: 'world', 102 | }, 103 | { 104 | name: entity.arrayComponent[1].name, 105 | hello: 'world', 106 | }, 107 | ], 108 | nestedComponent: { 109 | [nestedKeyA]: { 110 | name: nestedKeyA, 111 | hello: 'world', 112 | }, 113 | [nestedKeyB]: { 114 | name: nestedKeyB, 115 | hello: 'world', 116 | }, 117 | }, 118 | }, 119 | ], 120 | }); 121 | }); 122 | }); 123 | }); 124 | 125 | describe('deserializing', () => { 126 | let json, entityId, nestedNameA, nestedNameB; 127 | 128 | beforeEach(() => { 129 | entityId = chance.guid(); 130 | nestedNameA = chance.word(); 131 | nestedNameB = chance.word(); 132 | 133 | json = { 134 | entities: [ 135 | { 136 | id: entityId, 137 | emptyComponent: {}, 138 | simpleComponent: { testProp: chance.string() }, 139 | arrayComponent: [ 140 | { name: chance.word(), hello: chance.word() }, 141 | { name: chance.word(), hello: chance.word() }, 142 | ], 143 | nestedComponent: { 144 | [nestedNameA]: { 145 | name: nestedNameA, 146 | hello: chance.word(), 147 | }, 148 | [nestedNameB]: { 149 | name: nestedNameB, 150 | hello: chance.word(), 151 | }, 152 | }, 153 | }, 154 | ], 155 | }; 156 | 157 | world.deserialize(json); 158 | }); 159 | 160 | it('should serialize back into the same json', () => { 161 | expect(world.serialize()).toEqual(json); 162 | }); 163 | 164 | it('should have all the same components', () => { 165 | const entity = world.getEntity(entityId); 166 | 167 | expect(entity.has(EmptyComponent)).toBe(true); 168 | expect(entity.has(SimpleComponent)).toBe(true); 169 | expect(entity.has(ArrayComponent)).toBe(true); 170 | expect(entity.has(NestedComponent)).toBe(true); 171 | }); 172 | 173 | it('should get component properties correct', () => { 174 | const entity = world.getEntity(entityId); 175 | 176 | const expected = json.entities[0]; 177 | 178 | expect(entity.simpleComponent.testProp).toEqual( 179 | expected.simpleComponent.testProp 180 | ); 181 | 182 | expect(entity.nestedComponent[nestedNameA].name).toEqual( 183 | expected.nestedComponent[nestedNameA].name 184 | ); 185 | expect(entity.nestedComponent[nestedNameA].hello).toEqual( 186 | expected.nestedComponent[nestedNameA].hello 187 | ); 188 | 189 | expect(entity.nestedComponent[nestedNameB].name).toEqual( 190 | expected.nestedComponent[nestedNameB].name 191 | ); 192 | expect(entity.nestedComponent[nestedNameB].hello).toEqual( 193 | expected.nestedComponent[nestedNameB].hello 194 | ); 195 | }); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /tests/jest.setup.js: -------------------------------------------------------------------------------- 1 | import Chance from 'chance'; 2 | 3 | const chance = new Chance(); 4 | 5 | chance.mixin({ 6 | object: () => ({ 7 | [chance.word()]: chance.string(), 8 | }), 9 | }); 10 | 11 | global.chance = chance; 12 | -------------------------------------------------------------------------------- /tests/unit/Component.spec.js: -------------------------------------------------------------------------------- 1 | import { Chance } from 'chance'; 2 | import { Engine, Component } from '../../src/index'; 3 | import { EmptyComponent } from '../data/components'; 4 | 5 | describe('Component', () => { 6 | let engine, world, entity, onDestroyedStub, onAttachedStub; 7 | 8 | class TestComponent extends Component { 9 | onAttached() { 10 | onAttachedStub(); 11 | } 12 | onDestroyed() { 13 | onDestroyedStub(); 14 | } 15 | } 16 | 17 | class NestedComponent extends Component { 18 | static properties = { 19 | name: 'test', 20 | }; 21 | static allowMultiple = true; 22 | static keyProperty = 'name'; 23 | } 24 | 25 | class ArrayComponent extends Component { 26 | static properties = { 27 | name: 'a', 28 | }; 29 | static allowMultiple = true; 30 | } 31 | 32 | beforeEach(() => { 33 | engine = new Engine(); 34 | 35 | world = engine.createWorld(); 36 | 37 | onAttachedStub = jest.fn(); 38 | onDestroyedStub = jest.fn(); 39 | onDestroyedStub = jest.fn(); 40 | 41 | engine.registerComponent(EmptyComponent); 42 | engine.registerComponent(TestComponent); 43 | engine.registerComponent(NestedComponent); 44 | engine.registerComponent(ArrayComponent); 45 | 46 | entity = world.createEntity(); 47 | }); 48 | 49 | describe('attach', () => { 50 | let component; 51 | 52 | beforeEach(() => { 53 | entity.add(TestComponent); 54 | component = entity.testComponent; 55 | }); 56 | 57 | it('should call the onAttached handler', () => { 58 | expect(onAttachedStub).toHaveBeenCalledTimes(1); 59 | expect(onAttachedStub).toHaveBeenCalledWith(); 60 | }); 61 | 62 | it('should set the entity', () => { 63 | expect(component.entity).toBe(entity); 64 | }); 65 | }); 66 | 67 | describe('destroy', () => { 68 | let component; 69 | 70 | beforeEach(() => { 71 | entity.add(TestComponent); 72 | entity.add(NestedComponent, { name: 'a' }); 73 | entity.add(NestedComponent, { name: 'b' }); 74 | entity.add(ArrayComponent); 75 | entity.add(ArrayComponent); 76 | }); 77 | 78 | describe('when destroying a simple component', () => { 79 | beforeEach(() => { 80 | component = entity.testComponent; 81 | component.destroy(); 82 | }); 83 | 84 | it('should remove the component from the entity', () => { 85 | expect(entity.has(TestComponent)).toBe(false); 86 | }); 87 | 88 | it('should call the "onDestroyed" handler', () => { 89 | expect(onDestroyedStub).toHaveBeenCalledTimes(1); 90 | expect(onDestroyedStub).toHaveBeenCalledWith(); 91 | }); 92 | 93 | it('should call the "onDestroyed" handler', () => { 94 | expect(onDestroyedStub).toHaveBeenCalledTimes(1); 95 | expect(onDestroyedStub).toHaveBeenCalledWith(); 96 | }); 97 | 98 | it('should set the component "entity" to null', () => { 99 | expect(component.entity).toBeUndefined(); 100 | }); 101 | }); 102 | 103 | describe('when destroying a keyed component', () => { 104 | beforeEach(() => { 105 | component = entity.nestedComponent.b; 106 | component.destroy(); 107 | }); 108 | 109 | it('should remove the component from the entity', () => { 110 | expect(entity.nestedComponent.b).toBeUndefined(); 111 | }); 112 | 113 | it('should not remove the other nested component from the entity', () => { 114 | expect(entity.nestedComponent.a).toBeDefined(); 115 | }); 116 | 117 | it('should set the component "entity" to null', () => { 118 | expect(component.entity).toBeUndefined(); 119 | }); 120 | }); 121 | 122 | describe('when destroying an array component', () => { 123 | beforeEach(() => { 124 | component = entity.arrayComponent[1]; 125 | component.destroy(); 126 | }); 127 | 128 | it('should remove the component from the entity', () => { 129 | expect(entity.arrayComponent[1]).toBeUndefined(); 130 | }); 131 | 132 | it('should not remove the other array component from the entity', () => { 133 | expect(entity.arrayComponent[0]).toBeDefined(); 134 | }); 135 | 136 | it('should set the component "entity" to null', () => { 137 | expect(component.entity).toBeUndefined(); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('properties', () => { 143 | class PropertyComponent extends Component { 144 | static properties = { 145 | name: 'test', 146 | arr: ['value', 2, null, true], 147 | ob: { 148 | key: 'test', 149 | obarr: [6, null, false, 'value'], 150 | }, 151 | }; 152 | } 153 | let entity1, entity2; 154 | 155 | beforeEach(() => { 156 | engine.registerComponent(PropertyComponent); 157 | 158 | entity1 = world.createEntity(); 159 | entity2 = world.createEntity(); 160 | }); 161 | 162 | it('should deep-clone properties on construction', () => { 163 | entity1.add(PropertyComponent); 164 | entity2.add(PropertyComponent); 165 | 166 | expect(entity1.propertyComponent.arr).not.toBe( 167 | entity2.propertyComponent.arr 168 | ); 169 | expect(entity1.propertyComponent.arr).toEqual( 170 | entity2.propertyComponent.arr 171 | ); 172 | 173 | expect(entity1.propertyComponent.ob).not.toBe( 174 | entity2.propertyComponent.ob 175 | ); 176 | expect(entity1.propertyComponent.ob).toEqual( 177 | entity2.propertyComponent.ob 178 | ); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /tests/unit/ComponentRegistry.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/index'; 2 | import { ComponentRegistry } from '../../src/ComponentRegistry'; 3 | import { EmptyComponent } from '../data/components'; 4 | 5 | describe('ComponentRegistry', () => { 6 | let registry; 7 | 8 | beforeEach(() => { 9 | const engine = new Engine(); 10 | 11 | registry = new ComponentRegistry(engine); 12 | }); 13 | 14 | describe('get', () => { 15 | let expectedKey = 'emptyComponent'; 16 | 17 | beforeEach(() => { 18 | registry.register(EmptyComponent); 19 | }); 20 | 21 | it('should assign a _ckey', () => { 22 | expect(EmptyComponent.prototype._ckey).toBe(expectedKey); 23 | }); 24 | 25 | it('should return the component by key', () => { 26 | expect(registry.get(expectedKey)).toBe(EmptyComponent); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/unit/Engine.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/Engine'; 2 | import { World } from '../../src/World'; 3 | 4 | describe('Engine', () => { 5 | let engine; 6 | 7 | beforeEach(() => { 8 | engine = new Engine(); 9 | }); 10 | 11 | describe('createWorld', () => { 12 | let result; 13 | 14 | beforeEach(() => { 15 | result = engine.createWorld(); 16 | }); 17 | 18 | it('should create a World instance', () => { 19 | expect(result).toBeInstanceOf(World); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/unit/Entity.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/Engine'; 2 | import { EmptyComponent } from '../data/components'; 3 | import { Component } from '../../src/Component'; 4 | 5 | describe('Entity', () => { 6 | let world; 7 | 8 | class TestComponent extends Component {} 9 | class NestedComponent extends Component { 10 | static properties = { 11 | name: 'test', 12 | }; 13 | static allowMultiple = true; 14 | static keyProperty = 'name'; 15 | } 16 | class ArrayComponent extends Component { 17 | static properties = { 18 | name: 'a', 19 | }; 20 | static allowMultiple = true; 21 | } 22 | 23 | beforeEach(() => { 24 | const engine = new Engine(); 25 | 26 | engine.registerComponent(EmptyComponent); 27 | engine.registerComponent(TestComponent); 28 | engine.registerComponent(NestedComponent); 29 | engine.registerComponent(ArrayComponent); 30 | 31 | world = engine.createWorld(); 32 | }); 33 | 34 | describe('create', () => { 35 | let entity; 36 | 37 | beforeEach(() => { 38 | entity = world.createEntity(); 39 | }); 40 | 41 | it('should be able to recall by entity id', () => { 42 | const result = world.getEntity(entity.id); 43 | 44 | expect(result).toBe(entity); 45 | }); 46 | 47 | it('should set the isDestroyed flag to FALSE', () => { 48 | expect(entity.isDestroyed).toBe(false); 49 | }); 50 | 51 | it('should assign an ID', () => { 52 | expect(typeof entity.id).toBe('string'); 53 | }); 54 | }); 55 | 56 | describe('destroy', () => { 57 | let entity, 58 | emptyComponentDestroySpy, 59 | testComponentDestroySpy, 60 | nestedComponentDestroySpy, 61 | arrayComponentDestroySpy; 62 | 63 | beforeEach(() => { 64 | entity = world.createEntity(); 65 | entity.add(EmptyComponent); 66 | entity.add(TestComponent); 67 | entity.add(NestedComponent); 68 | entity.add(ArrayComponent); 69 | 70 | testComponentDestroySpy = jest.spyOn( 71 | entity.testComponent, 72 | '_onDestroyed' 73 | ); 74 | emptyComponentDestroySpy = jest.spyOn( 75 | entity.emptyComponent, 76 | '_onDestroyed' 77 | ); 78 | 79 | nestedComponentDestroySpy = jest.spyOn( 80 | entity.nestedComponent.test, 81 | '_onDestroyed' 82 | ); 83 | arrayComponentDestroySpy = jest.spyOn( 84 | entity.arrayComponent[0], 85 | '_onDestroyed' 86 | ); 87 | 88 | entity.destroy(); 89 | }); 90 | 91 | it('should no longer be able to recall by entity id', () => { 92 | const result = world.getEntity(entity.id); 93 | 94 | expect(result).toBeUndefined(); 95 | }); 96 | 97 | it('should set the entity "isDestroyed" flag to TRUE', () => { 98 | expect(entity.isDestroyed).toBe(true); 99 | }); 100 | 101 | it('should call "onDestroyed" for all components', () => { 102 | expect(testComponentDestroySpy).toHaveBeenCalledTimes(1); 103 | expect(testComponentDestroySpy).toHaveBeenCalledWith(); 104 | expect(emptyComponentDestroySpy).toHaveBeenCalledTimes(1); 105 | expect(emptyComponentDestroySpy).toHaveBeenCalledWith(); 106 | expect(nestedComponentDestroySpy).toHaveBeenCalledTimes(1); 107 | expect(nestedComponentDestroySpy).toHaveBeenCalledWith(); 108 | expect(arrayComponentDestroySpy).toHaveBeenCalledTimes(1); 109 | expect(arrayComponentDestroySpy).toHaveBeenCalledWith(); 110 | }); 111 | }); 112 | 113 | describe('add', () => { 114 | let entity; 115 | 116 | beforeEach(() => { 117 | entity = world.createEntity(); 118 | }); 119 | 120 | describe('simple components', () => { 121 | beforeEach(() => { 122 | entity.add(TestComponent); 123 | }); 124 | 125 | it('should add the component to the entity as a camel cased property', () => { 126 | expect(entity.testComponent).toBeDefined(); 127 | }); 128 | 129 | it('should include the component in the entity list', () => { 130 | expect(entity.components.testComponent).toBeTruthy(); 131 | }); 132 | 133 | it('should have the correct type of components', () => { 134 | expect(entity.testComponent).toBeInstanceOf(TestComponent); 135 | }); 136 | }); 137 | 138 | describe('keyed components', () => { 139 | let nameA, nameB; 140 | 141 | beforeEach(() => { 142 | nameA = chance.word(); 143 | nameB = chance.word(); 144 | 145 | entity.add(NestedComponent, { name: nameA }); 146 | entity.add(NestedComponent, { name: nameB }); 147 | }); 148 | 149 | it('should add the components to the entity as a camel cased property', () => { 150 | expect(entity.nestedComponent[nameA]).toBeDefined(); 151 | expect(entity.nestedComponent[nameB]).toBeDefined(); 152 | }); 153 | 154 | it('should have the correct type of components', () => { 155 | expect(entity.nestedComponent[nameA]).toBeInstanceOf( 156 | NestedComponent 157 | ); 158 | expect(entity.nestedComponent[nameB]).toBeInstanceOf( 159 | NestedComponent 160 | ); 161 | }); 162 | 163 | it('should set component properties', () => { 164 | expect(entity.nestedComponent[nameA].name).toBe(nameA); 165 | expect(entity.nestedComponent[nameB].name).toBe(nameB); 166 | }); 167 | }); 168 | 169 | describe('array components', () => { 170 | let nameA, nameB; 171 | 172 | beforeEach(() => { 173 | nameA = chance.word(); 174 | nameB = chance.word(); 175 | 176 | entity.add(ArrayComponent, { name: nameA }); 177 | entity.add(ArrayComponent, { name: nameB }); 178 | }); 179 | 180 | it('should add the components to the entity as a camel cased array property', () => { 181 | expect(entity.arrayComponent).toBeDefined(); 182 | expect(entity.arrayComponent).toHaveLength(2); 183 | expect(entity.arrayComponent[0].name).toBe(nameA); 184 | expect(entity.arrayComponent[1].name).toBe(nameB); 185 | }); 186 | 187 | it('should assign the entity', () => { 188 | expect(entity.arrayComponent[0].entity).toBe(entity); 189 | expect(entity.arrayComponent[1].entity).toBe(entity); 190 | }); 191 | 192 | it('should include the components in the entity list', () => { 193 | expect(entity.components.arrayComponent).toHaveLength(2); 194 | }); 195 | 196 | it('should have the correct type of components', () => { 197 | expect(entity.arrayComponent[0]).toBeInstanceOf(ArrayComponent); 198 | expect(entity.arrayComponent[1]).toBeInstanceOf(ArrayComponent); 199 | }); 200 | 201 | it('should set component properties', () => { 202 | expect(entity.arrayComponent[0].name).toBe(nameA); 203 | expect(entity.arrayComponent[1].name).toBe(nameB); 204 | }); 205 | }); 206 | }); 207 | 208 | describe('remove', () => { 209 | let entity; 210 | 211 | beforeEach(() => { 212 | entity = world.createEntity(); 213 | }); 214 | 215 | describe('simple components', () => { 216 | beforeEach(() => { 217 | entity.add(TestComponent); 218 | entity.remove(entity.testComponent); 219 | }); 220 | 221 | it('should set the component to undefined', () => { 222 | expect(entity.testComponent).toBeUndefined(); 223 | }); 224 | }); 225 | 226 | describe('keyed components', () => { 227 | beforeEach(() => { 228 | entity.add(NestedComponent, { name: 'a' }); 229 | entity.add(NestedComponent, { name: 'b' }); 230 | }); 231 | 232 | describe('when one of them is removed', () => { 233 | beforeEach(() => { 234 | entity.remove(entity.nestedComponent.b); 235 | }); 236 | 237 | it('should set the component to undefined', () => { 238 | expect(entity.testComponent).toBeUndefined(); 239 | }); 240 | }); 241 | 242 | describe('when both are removed', () => { 243 | beforeEach(() => { 244 | entity.remove(entity.nestedComponent.a); 245 | entity.remove(entity.nestedComponent.b); 246 | }); 247 | 248 | it('should set it to undefined', () => { 249 | expect(entity.nestedComponent).toBeUndefined(); 250 | }); 251 | }); 252 | }); 253 | 254 | describe('array components', () => { 255 | beforeEach(() => { 256 | entity.add(ArrayComponent, { name: 'a' }); 257 | entity.add(ArrayComponent, { name: 'b' }); 258 | }); 259 | 260 | describe('when one of them is removed', () => { 261 | beforeEach(() => { 262 | const instance = entity.arrayComponent[1]; 263 | 264 | entity.remove(instance); 265 | }); 266 | 267 | it('should set the component to undefined', () => { 268 | expect(entity.arrayComponent[1]).toBeUndefined(); 269 | }); 270 | 271 | it('should keep the other component', () => { 272 | expect(entity.arrayComponent[0]).toBeDefined(); 273 | }); 274 | }); 275 | 276 | describe('when both are removed', () => { 277 | beforeEach(() => { 278 | const instanceA = entity.arrayComponent[0]; 279 | const instanceB = entity.arrayComponent[1]; 280 | 281 | entity.remove(instanceA); 282 | entity.remove(instanceB); 283 | }); 284 | 285 | it('should set it to undefined', () => { 286 | expect(entity.arrayComponent).toBeUndefined(); 287 | }); 288 | }); 289 | }); 290 | }); 291 | }); 292 | -------------------------------------------------------------------------------- /tests/unit/Prefab.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/index'; 2 | import { 3 | EmptyComponent, 4 | SimpleComponent, 5 | ArrayComponent, 6 | NestedComponent, 7 | } from '../data/components'; 8 | import { 9 | SimplePrefab, 10 | EmptyPrefab, 11 | PrefabWithKeyedAndArray, 12 | } from '../data/prefabs'; 13 | 14 | describe('Entity', () => { 15 | let engine, world; 16 | 17 | beforeEach(() => { 18 | engine = new Engine(); 19 | 20 | engine.registerComponent(EmptyComponent); 21 | engine.registerComponent(SimpleComponent); 22 | engine.registerComponent(ArrayComponent); 23 | engine.registerComponent(NestedComponent); 24 | 25 | world = engine.createWorld(); 26 | }); 27 | 28 | describe('prefab with no components', () => { 29 | let entity; 30 | 31 | beforeEach(() => { 32 | engine.registerPrefab(EmptyPrefab); 33 | 34 | entity = world.createPrefab('EmptyPrefab'); 35 | }); 36 | 37 | it('should create an entity', () => { 38 | expect(entity).toBeDefined(); 39 | }); 40 | }); 41 | 42 | describe('prefab with basic components', () => { 43 | let entity; 44 | 45 | beforeEach(() => { 46 | engine.registerPrefab(SimplePrefab); 47 | }); 48 | 49 | describe('with no prop overrides', () => { 50 | beforeEach(() => { 51 | entity = world.createPrefab('SimplePrefab'); 52 | }); 53 | 54 | it('should create an entity', () => { 55 | expect(entity).toBeDefined(); 56 | }); 57 | 58 | it('should have the components', () => { 59 | expect(entity.has(EmptyComponent)).toBe(true); 60 | expect(entity.has(SimpleComponent)).toBe(true); 61 | }); 62 | 63 | it('should assign component prop data from the prefab', () => { 64 | expect(entity.simpleComponent.testProp).toBe('testPropValue'); 65 | }); 66 | }); 67 | 68 | describe('with initial props', () => { 69 | let initialProps; 70 | 71 | beforeEach(() => { 72 | initialProps = { 73 | simpleComponent: { 74 | testProp: chance.string(), 75 | }, 76 | }; 77 | 78 | entity = world.createPrefab('SimplePrefab', initialProps); 79 | }); 80 | 81 | it('should create an entity', () => { 82 | expect(entity).toBeDefined(); 83 | }); 84 | 85 | it('should have the components', () => { 86 | expect(entity.has(EmptyComponent)).toBe(true); 87 | expect(entity.has(SimpleComponent)).toBe(true); 88 | }); 89 | 90 | it('should assign component prop data from the initial props', () => { 91 | expect(entity.simpleComponent.testProp).toBe( 92 | initialProps.simpleComponent.testProp 93 | ); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('prefab with array and keyed components', () => { 99 | let entity; 100 | 101 | beforeEach(() => { 102 | engine.registerPrefab(PrefabWithKeyedAndArray); 103 | }); 104 | 105 | describe('with no prop overrides', () => { 106 | beforeEach(() => { 107 | entity = world.createPrefab('PrefabWithKeyedAndArray'); 108 | }); 109 | 110 | it('should create an entity', () => { 111 | expect(entity).toBeDefined(); 112 | }); 113 | 114 | it('should have the components', () => { 115 | expect(entity.has(ArrayComponent)).toBe(true); 116 | expect(entity.has(NestedComponent)).toBe(true); 117 | expect(entity.arrayComponent).toHaveLength(2); 118 | expect(entity.nestedComponent.one).toBeDefined(); 119 | expect(entity.nestedComponent.two).toBeDefined(); 120 | }); 121 | 122 | it('should assign component prop data from the prefab', () => { 123 | expect(entity.arrayComponent[0].name).toBe('a'); 124 | expect(entity.arrayComponent[1].name).toBe('b'); 125 | expect(entity.nestedComponent.one.name).toBe('one'); 126 | expect(entity.nestedComponent.two.name).toBe('two'); 127 | }); 128 | }); 129 | 130 | describe('with initial props', () => { 131 | let initialProps; 132 | 133 | beforeEach(() => { 134 | initialProps = { 135 | nestedComponent: { 136 | one: { hello: chance.word() }, 137 | two: { hello: chance.word() }, 138 | }, 139 | arrayComponent: [ 140 | { name: chance.word() }, 141 | { name: chance.word() }, 142 | ], 143 | }; 144 | 145 | entity = world.createPrefab( 146 | 'PrefabWithKeyedAndArray', 147 | initialProps 148 | ); 149 | }); 150 | 151 | it('should create an entity', () => { 152 | expect(entity).toBeDefined(); 153 | }); 154 | 155 | it('should have the components', () => { 156 | expect(entity.has(ArrayComponent)).toBe(true); 157 | expect(entity.has(NestedComponent)).toBe(true); 158 | expect(entity.arrayComponent).toHaveLength(2); 159 | expect(entity.nestedComponent.one).toBeDefined(); 160 | expect(entity.nestedComponent.two).toBeDefined(); 161 | }); 162 | 163 | it('should assign component prop data from the initial props', () => { 164 | expect(entity.arrayComponent[0].name).toBe( 165 | initialProps.arrayComponent[0].name 166 | ); 167 | expect(entity.arrayComponent[1].name).toBe( 168 | initialProps.arrayComponent[1].name 169 | ); 170 | expect(entity.nestedComponent.one.hello).toBe( 171 | initialProps.nestedComponent.one.hello 172 | ); 173 | expect(entity.nestedComponent.two.hello).toBe( 174 | initialProps.nestedComponent.two.hello 175 | ); 176 | }); 177 | }); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /tests/unit/Query.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine, Component } from '../../src/index'; 2 | import { bitIntersection } from '../../src/util/bit-util'; 3 | 4 | describe('Query', () => { 5 | let world, entity, result, query; 6 | 7 | class ComponentA extends Component {} 8 | class ComponentB extends Component {} 9 | class ComponentC extends Component {} 10 | 11 | beforeEach(() => { 12 | const engine = new Engine(); 13 | 14 | engine.registerComponent(ComponentA); 15 | engine.registerComponent(ComponentB); 16 | engine.registerComponent(ComponentC); 17 | 18 | world = engine.createWorld(); 19 | entity = world.createEntity(); 20 | }); 21 | 22 | describe('any', () => { 23 | it('should return false for an empty entity', () => { 24 | query = world.createQuery({ 25 | any: [ComponentA], 26 | }); 27 | 28 | expect(query.has(entity)).toBe(false); 29 | }); 30 | 31 | it('should return true if the entity has it', () => { 32 | query = world.createQuery({ 33 | any: [ComponentA], 34 | }); 35 | 36 | entity.add(ComponentA); 37 | 38 | expect(query.has(entity)).toBe(true); 39 | }); 40 | 41 | it('should return true if the entity has at least one of them', () => { 42 | query = world.createQuery({ 43 | any: [ComponentA, ComponentB, ComponentC], 44 | }); 45 | 46 | entity.add(ComponentC); 47 | 48 | expect(query.has(entity)).toBe(true); 49 | }); 50 | 51 | it('should return false if the entity does not have it', () => { 52 | query = world.createQuery({ 53 | any: [ComponentA], 54 | }); 55 | 56 | entity.add(ComponentB); 57 | 58 | expect(query.has(entity)).toBe(false); 59 | }); 60 | }); 61 | 62 | describe('all', () => { 63 | it('should return false for an empty entity', () => { 64 | query = world.createQuery({ 65 | all: [ComponentA], 66 | }); 67 | 68 | expect(query.has(entity)).toBe(false); 69 | }); 70 | 71 | it('should return true if the entity has it', () => { 72 | query = world.createQuery({ 73 | all: [ComponentA], 74 | }); 75 | 76 | entity.add(ComponentA); 77 | 78 | expect(query.has(entity)).toBe(true); 79 | }); 80 | 81 | it('should return true if the entity has all of them', () => { 82 | query = world.createQuery({ 83 | all: [ComponentA, ComponentB, ComponentC], 84 | }); 85 | 86 | entity.add(ComponentA); 87 | entity.add(ComponentB); 88 | entity.add(ComponentC); 89 | 90 | expect(query.has(entity)).toBe(true); 91 | }); 92 | 93 | it('should return false if the entity is missing one of them', () => { 94 | query = world.createQuery({ 95 | all: [ComponentA, ComponentB, ComponentC], 96 | }); 97 | 98 | entity.add(ComponentA); 99 | entity.add(ComponentB); 100 | 101 | expect(query.has(entity)).toBe(false); 102 | }); 103 | }); 104 | 105 | describe('none', () => { 106 | it('should return true for an empty entity', () => { 107 | query = world.createQuery({ 108 | none: [ComponentA], 109 | }); 110 | 111 | expect(query.has(entity)).toBe(true); 112 | }); 113 | 114 | it('should return false if the entity has it', () => { 115 | query = world.createQuery({ 116 | none: [ComponentA], 117 | }); 118 | 119 | entity.add(ComponentA); 120 | 121 | expect(query.has(entity)).toBe(false); 122 | }); 123 | 124 | it('should return false if the entity has all of them', () => { 125 | query = world.createQuery({ 126 | none: [ComponentA, ComponentB, ComponentC], 127 | }); 128 | 129 | entity.add(ComponentA); 130 | entity.add(ComponentB); 131 | entity.add(ComponentC); 132 | 133 | expect(query.has(entity)).toBe(false); 134 | }); 135 | 136 | it('should return false if the entity is missing one of them', () => { 137 | query = world.createQuery({ 138 | none: [ComponentA, ComponentB, ComponentC], 139 | }); 140 | 141 | entity.add(ComponentA); 142 | entity.add(ComponentB); 143 | 144 | expect(query.has(entity)).toBe(false); 145 | }); 146 | 147 | it('should return false if the entity has one of them', () => { 148 | query = world.createQuery({ 149 | none: [ComponentA, ComponentB, ComponentC], 150 | }); 151 | 152 | entity.add(ComponentA); 153 | 154 | expect(query.has(entity)).toBe(false); 155 | }); 156 | }); 157 | 158 | describe('combinations', () => { 159 | it('should return true if it matches criteria', () => { 160 | query = world.createQuery({ 161 | any: [ComponentA], 162 | all: [ComponentB, ComponentC], 163 | }); 164 | 165 | entity.add(ComponentA); 166 | entity.add(ComponentB); 167 | entity.add(ComponentC); 168 | 169 | expect(query.has(entity)).toBe(true); 170 | }); 171 | 172 | it('should return true if it matches criteria', () => { 173 | query = world.createQuery({ 174 | any: [ComponentA, ComponentB], 175 | all: [ComponentC], 176 | }); 177 | 178 | entity.add(ComponentA); 179 | entity.add(ComponentC); 180 | 181 | expect(query.has(entity)).toBe(true); 182 | }); 183 | 184 | it('should return true if it matches criteria', () => { 185 | query = world.createQuery({ 186 | any: [ComponentA, ComponentB], 187 | none: [ComponentC], 188 | }); 189 | 190 | entity.add(ComponentA); 191 | entity.add(ComponentB); 192 | 193 | expect(query.has(entity)).toBe(true); 194 | }); 195 | 196 | it('should return false if it does not match criteria', () => { 197 | query = world.createQuery({ 198 | any: [ComponentA], 199 | all: [ComponentB, ComponentC], 200 | }); 201 | 202 | entity.add(ComponentA); 203 | entity.add(ComponentB); 204 | 205 | expect(query.has(entity)).toBe(false); 206 | }); 207 | 208 | it('should return false if it does not match criteria', () => { 209 | query = world.createQuery({ 210 | any: [ComponentA, ComponentB], 211 | all: [ComponentC], 212 | }); 213 | 214 | entity.add(ComponentC); 215 | 216 | expect(query.has(entity)).toBe(false); 217 | }); 218 | 219 | it('should return false if it does not match criteria', () => { 220 | query = world.createQuery({ 221 | any: [ComponentA, ComponentB], 222 | none: [ComponentC], 223 | }); 224 | 225 | entity.add(ComponentA); 226 | entity.add(ComponentB); 227 | entity.add(ComponentC); 228 | 229 | expect(query.has(entity)).toBe(false); 230 | }); 231 | }); 232 | 233 | describe('callbacks', () => { 234 | it('should invoke multiple callback', () => { 235 | query = world.createQuery({ 236 | any: [ComponentA], 237 | }); 238 | 239 | const onAddedCb1 = jest.fn(); 240 | const onAddedCb2 = jest.fn(); 241 | const onRemovedCb1 = jest.fn(); 242 | const onRemovedCb2 = jest.fn(); 243 | 244 | query.onEntityAdded(onAddedCb1); 245 | query.onEntityAdded(onAddedCb2); 246 | query.onEntityRemoved(onRemovedCb1); 247 | query.onEntityRemoved(onRemovedCb2); 248 | 249 | entity.add(ComponentA); 250 | 251 | expect(onAddedCb1).toHaveBeenCalledTimes(1); 252 | expect(onAddedCb1).toHaveBeenCalledWith(entity); 253 | expect(onAddedCb2).toHaveBeenCalledTimes(1); 254 | expect(onAddedCb2).toHaveBeenCalledWith(entity); 255 | 256 | entity.componentA.destroy(); 257 | 258 | expect(onRemovedCb1).toHaveBeenCalledTimes(1); 259 | expect(onRemovedCb1).toHaveBeenCalledWith(entity); 260 | expect(onRemovedCb2).toHaveBeenCalledTimes(1); 261 | expect(onRemovedCb2).toHaveBeenCalledWith(entity); 262 | }); 263 | }); 264 | }); 265 | -------------------------------------------------------------------------------- /tests/unit/World.spec.js: -------------------------------------------------------------------------------- 1 | import { Engine } from '../../src/index'; 2 | 3 | describe('World', () => { 4 | let world; 5 | 6 | beforeEach(() => { 7 | const engine = new Engine(); 8 | 9 | world = engine.createWorld(); 10 | }); 11 | 12 | describe('createEntity', () => { 13 | let entity; 14 | 15 | describe('without an ID', () => { 16 | beforeEach(() => { 17 | entity = world.createEntity(); 18 | }); 19 | 20 | it('should be able to recall the entity by id', () => { 21 | const result = world.getEntity(entity.id); 22 | 23 | expect(result).toBe(entity); 24 | }); 25 | }); 26 | 27 | describe('with an ID', () => { 28 | let givenId; 29 | 30 | beforeEach(() => { 31 | givenId = chance.guid(); 32 | entity = world.createEntity(givenId); 33 | }); 34 | 35 | it('should assign the ID to the entity', () => { 36 | expect(entity.id).toBe(givenId); 37 | }); 38 | 39 | it('should be able to recall the entity by id', () => { 40 | const result = world.getEntity(givenId); 41 | 42 | expect(result).toBe(entity); 43 | }); 44 | }); 45 | }); 46 | 47 | describe('destroyEntity', () => { 48 | let entity; 49 | 50 | beforeEach(() => { 51 | entity = world.createEntity(); 52 | 53 | world.destroyEntity(entity.id); 54 | }); 55 | 56 | it('should no longer be able to recall by entity id', () => { 57 | const result = world.getEntity(entity.id); 58 | 59 | expect(result).toBeUndefined(); 60 | }); 61 | }); 62 | }); 63 | --------------------------------------------------------------------------------