diff --git a/.gitignore b/.gitignore index 28c038a..e25d351 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules dist/ +# Ignores generated js files in TS examples +./examples/typescript/**/*.js + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c464fbd..5545659 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,3 +73,20 @@ import { World } from 'ecstra'; // You can use Ecstra as if it was a npm-installed dependency const world = new World(); ``` + +## Benchmark + +Before being merged, the code is passed into the benchmark test suite to ensure +no performance has been lost. + +To run the benchmark locally, you can use: + +```sh +npm run benchmark -- -o [PATH_TO_OUTPUT_FILE] +``` + +If you wish to compare the benchmark with a previous result, you can use: + +```sh +npm run benchmark -- -c [PATH_TO_FILE_TO_COMPARE] +``` diff --git a/DOC.md b/DOC.md index f1baa3a..b324b48 100644 --- a/DOC.md +++ b/DOC.md @@ -182,7 +182,7 @@ the other, one group at a time. Running systems can query entities based on the ## Example ```js -import { NumberProp, System } from 'fecs'; +import { NumberProp, System } from 'ecstra'; class TransformComponent extends ComponentData { } TransformComponent.Properties = { @@ -226,7 +226,7 @@ Queries can also specify that they want to deal with entities that **do not** have a given component: ```js -import { Not } from 'fecs'; +import { Not } from 'ecstra'; PhysicsSystem.Queries = { entitiesWithBoxThatArentPlayers: [ @@ -323,82 +323,109 @@ class TestComponentDecorator extends ComponentData { } ``` -# Advanced +# Pooling -## Custom Properties +The first version of Ecstra had pooling disabled by default. However, when I +started to benchmark the library I quickly realized that pooling was a must have +by default. -You can create your own properties by extending the `Property` class: +By default, every component type and entities have associated pools. If you have +50 different components, Ecstra will then allocates 50 component pools and one +extra pool for entities. This may seem like a waste of memory, but will bring +by ~50% the cost of creating components and entities. -```js -import { Property } from 'property'; +## Custom Pool -class MyProperty extends Property { - - copy(dest, src) { - // Optional method to implement. - // `dest` should receive the value (for reference type). - // `src` is the input. - return dest; - } +You can derive your own pool implementation by creating a class +matching this interface: +```js +export interface ObjectPool { + destroy?: () => void; + acquire(): T; + release(value: T): void; + expand(count: number): void; } ``` -You can also create a function that setup your property: +You can then use your own default pool for entities / components: ```js -function MyProp(options) { - // Process the `options` argument and create the property. - return new MyProperty(...); -} +const world = new World({ + ComponentPoolClass: MySuperFastPoolClass, + EntityPoolClass: MySuperFastPoolClass +}); ``` -## Pooling - -When creating and destroying a lot of entities and components, pooling -can help reduce garbage collection and improve general performance. +Alternatively, you can change the pool on a per-component basis using: -> Note By default, worlds are created in _"manual"_ pooling mode, i.e., no pooling is performed. - -### Automatic Pooling +```js +world.registerComponent(MyComponentClass, { pool: MySuperFastPoolClass }); +``` -It's possible for you to activate pooling with little effort: +or ```js -const world = new World({ useManualPooling: false }); +world.setComponentPool(MyComponentClass, MySuperFastPoolClass); ``` -Pooling will be enabled for **every** components, as well as for -entities. +## Disable Automatic Pooling -It's possible for you to opt out pooling for the desired components: +If you don't want any default pooling, you can create your `World` using: ```js -// The second argument represents the pool. Setting it to `null` -// disable pooling. -world.setComponentPool(MyComponentClass, null); +const world = new World({ + useManualPooling: true +}) ``` -### Manual Pooling +When the automatic pooling is disabled, `ComponentPoolClass` and +`EntityPoolClass` are unused. However, manually assigning pool using +`world.setComponentPool` is still a possibility. -Instead of using automatic pooling, it's possible to manage pools -one by one. You can assign a pool to a component type: +# Perfomance + +## Pooling + +Pooling can significantly improve performance, especially if you often add or +remove components. The default pooling scheme should be enough in most cases, +but creating custom pool systems can also help. + +## Reduce Componet Addition / Deletion + +Even if pooling is used, adding / deleting components always comes at a cost. +The components list is hashed into a string, used to find the new archetype +of the entity. + +You can probably enabled / disable some components by using a custom field. + +# Advanced + +## Custom Properties + +You can create your own properties by extending the `Property` class: ```js -import { DefaultPool } from 'ecstra'; +import { Property } from 'property'; -world.setComponentPool(MyComponentClass, new DefaultPool()); +class MyProperty extends Property { + + copy(dest, src) { + // Optional method to implement. + // `dest` should receive the value (for reference type). + // `src` is the input. + return dest; + } + +} ``` -You can also derive your own pool implementation by creating a class -matching this interface: +You can also create a function that setup your property: ```js -export interface ObjectPool { - destroy?: () => void; - acquire(): T; - release(value: T): void; - expand(count: number): void; +function MyProp(options) { + // Process the `options` argument and create the property. + return new MyProperty(...); } ``` diff --git a/README.md b/README.md index 4218f84..a054e57 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Fast & Flexible EntityComponentSystem (ECS) for JavaScript and Typescript, avail Get started with: * The [Documentation](./DOC.md) -* The [Examples](./example) +* The [JavaScript Examples](./example) +* The [TypeScript Examples](./example/typescript) > 🔍 I am currently looking for people to help me to identify their needs in order to drive the development of this [library further](#stable-version). @@ -22,7 +23,7 @@ Get started with: > Created as 'Flecs', it's been renamed to 'Ecstra' to avoid duplicate -Ecstra (pronounced as "eck-stra") is heavily based on [Ecsy](https://github.com/ecsyjs/ecsy), but mixes concepts from other great ECS. It also share some concepts with +Ecstra (pronounced as "extra") is heavily based on [Ecsy](https://github.com/ecsyjs/ecsy), but mixes concepts from other great ECS. It also share some concepts with [Hecs](https://github.com/gohyperr/hecs/). My goals for the library is to keep it: @@ -45,6 +46,7 @@ The library will prioritize stability improvements over feature development. * TypeScript Decorators * For component properties * For system ordering and configuration +* No Dependency ## Install @@ -66,12 +68,108 @@ The library is distributed as an ES6 module, but also comes with two UMD builds: ## Usage Example +### TypeScript + +```ts +import { + ComponentData, + TagComponent, + System, + World, + number, + queries, + ref +} from 'ecstra'; + +/** + * Components definition. + */ + +class Position2D extends ComponentData { + @number() + x!: number; + @number() + y!: number; +} + +class FollowTarget extends ComponentData { + @ref() + target!: number; + @number(1.0) + speed!: number; +} + +class PlayerTag extends TagComponent {} +class ZombieTag extends TagComponent {} + +/** + * Systems definition. + */ + +@queries({ + // Select entities with all three components `ZombieTag`, `FollowTarget`, and + // `Position2D`. + zombies: [ZombieTag, FollowTarget, Position2D] +}) +class ZombieFollowSystem extends System { + + execute(delta: number): void { + this.queries.zombies.execute((entity) => { + const { speed, target } = entity.read(FollowTarget); + const position = entity.write(Position2D); + const deltaX = target.x - position.x; + const deltaY = target.y - position.y; + const len = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + if (len >= 0.00001) { + position.x += speed * delta * (deltaX / len); + position.y += speed * delta * (deltaY / len); + } + }); + } + +} + +const world = new World().register(ZombieFollowSystem); + +// Creates a player entity. +const playerEntity = world.create().add(PlayerTag).add(Position2D); +const playerPosition = playerEntity.read(); + +// Creates 100 zombies at random positions with a `FollowTarget` component that +// will make them follow our player. +for (let i = 0; i < 100; ++i) { + world.create() + .add(ZombieTag) + .add(Position2D, { + x: Math.floor(Math.random() * 50.0) - 100.0, + y: Math.floor(Math.random() * 50.0) - 100.0 + }) + .add(FollowTarget, { target: playerPosition }) +} + +// Runs the animation loop and execute all systems every frame. + +let lastTime = 0.0; +function loop() { + const currTime = performance.now(); + const deltaTime = currTime - lastTime; + lastTime = currTime; + world.execute(deltaTime); + requestAnimationFrame(loop); +} +lastTime = performance.now(); +loop(); +``` + +### JavaScript + ```js import { ComponentData, TagComponent, NumberProp, RefProp, + System, World } from 'ecstra'; @@ -121,7 +219,7 @@ ZombieFollowSystem.Queries = { zombies: [ZombieTag, FollowTarget, Position2D] } -const world = new World(); +const world = new World().register(ZombieFollowSystem); // Creates a player entity. const playerEntity = world.create().add(PlayerTag).add(Position2D); @@ -153,6 +251,39 @@ lastTime = performance.now(); loop(); ``` +## Running Examples + +In order to try the examples, you need to build the library using: + +```sh +yarn build # Alternatively, `yarn start` to watch the files +``` + +You can then start the examples web server using: + +```sh +yarn example +``` + +### TS Examples + +TypeScript versions of the examples are available [here](.examples/typescript). +If you only want to see the example running, you can run the JS ones as they +are identicial. + +If you want to run the TypeScript examples themselves, please build the examples +first: + +```sh +yarn example:build # Alternatively, `yarn example:start` to watch the files +``` + +And then run the examples web server: + +```sh +yarn example +``` + ## Stable Version The library is brand new and it's the perfect time for me to taylor it to match as much as possible most of the developer needs. diff --git a/benchmark/benchmark-macbookpro18.json b/benchmark/benchmark-macbookpro18.json new file mode 100644 index 0000000..009b203 --- /dev/null +++ b/benchmark/benchmark-macbookpro18.json @@ -0,0 +1,57 @@ +{ + "benchmarks": [ + { + "name": "Entity", + "samples": [ + { + "name": "create / destroy entities without pool", + "iterations": 25, + "average": 0.38087823867797854, + "memoryAverage": 283723.52 + }, + { + "name": "create / destroy entities with pool", + "iterations": 25, + "average": 0.20508251428604127, + "memoryAverage": 112702.08 + }, + { + "name": "add tag component to entity - no pooling", + "iterations": 25, + "average": 0.13757079362869262, + "memoryAverage": 148817.6 + }, + { + "name": "add tag component to entity - pooling", + "iterations": 75, + "average": 0.08282259782155355, + "memoryAverage": 159372.58666666667 + }, + { + "name": "remove tag component synchronously from entity", + "iterations": 75, + "average": 0.08911222298940023, + "memoryAverage": 142466.56 + }, + { + "name": "add data component to entity - no pooling", + "iterations": 75, + "average": 0.1485378122329712, + "memoryAverage": 58722.346666666665 + }, + { + "name": "add data component to entity - pooling", + "iterations": 75, + "average": 0.09143120686213176, + "memoryAverage": 127617.81333333334 + }, + { + "name": "add tag component to entity - several queries", + "iterations": 25, + "average": 0.15171464204788207, + "memoryAverage": 8212.16 + } + ] + } + ] +} diff --git a/benchmark/benchmark.ts b/benchmark/benchmark.ts new file mode 100644 index 0000000..75f37b6 --- /dev/null +++ b/benchmark/benchmark.ts @@ -0,0 +1,234 @@ +import { performance } from 'perf_hooks'; + +/** + * Objects gathering statis on start / stop + */ +class Stats { + /** + * Number of iterations performed, used to average values + * + * @hidden + */ + private _count: number; + + /** + * Number of iterations performed where memory was summed, used to average + * values + * + * @hidden + */ + private _memCount: number; + + /** + * Summation of the time spent by each iteration, in ms + * + * @hidden + */ + private _elapsed: number; + + /** + * Summation of the memory used by each iteration, in bytes + * + * @hidden + */ + private _memory: number; + + /** + * Start time value, stored on the last call to [[Stats.start]] + * + * @hidden + */ + private _lastTimeStamp: number; + + /** + * Start memory value, stored on the last call to [[Stats.start]] + * + * @hidden + */ + private _lastMemory: number; + + public constructor() { + this._count = 0; + this._memCount = 0; + this._elapsed = 0.0; + this._memory = 0.0; + this._lastTimeStamp = 0.0; + this._lastMemory = 0.0; + } + + public start(): void { + this._lastTimeStamp = performance.now(); + this._lastMemory = process.memoryUsage().heapUsed; + } + + public stop(): void { + const time = performance.now() - this._lastTimeStamp; + const memory = process.memoryUsage().heapUsed - this._lastMemory; + + this._elapsed += time; + ++this._count; + + // Unfortunately, the garbage collection can automatically occurs before + // the sample is done. In this case, we simply ignore this iteration memory + // footprint. + if (memory >= 0) { + this._memory += memory; + ++this._memCount; + } + } + + public get average(): number { + return this._elapsed / this._count; + } + + public get memoryAverage(): number { + return this._memory / this._memCount; + } +} + +class BenchmarkGroup { + private _name: string; + private _samples: Sample[]; + + public constructor(name: string) { + this._name = name; + this._samples = []; + } + + public add(sample: Sample): this { + this._samples.push(sample); + return this; + } + + public get name(): string { + return this._name; + } + + public get samples(): Sample[] { + return this._samples; + } +} + +/** + * Create and run benchmarks + */ +export class Benchmark { + /** + * List of groups created in this benchmark + * + * @hidden + */ + private _groups: Map; + + /** + * Called triggered when a sample is starting + * + * @hidden + */ + private _onSampleStart: (sample: Sample) => void; + + /** + * Called triggered after a sample is completed + * + * @hidden + */ + private _onSampleComplete: (sample: BenchmarkSampleResult) => void; + + public constructor() { + this._groups = new Map(); + this._onSampleStart = () => { + /* Empty. */ + }; + this._onSampleComplete = () => { + /* Empty. */ + }; + } + + public group(name: string): BenchmarkGroup { + if (!this._groups.has(name)) { + this._groups.set(name, new BenchmarkGroup(name)); + } + return this._groups.get(name)!; + } + + public onSampleStart(cb: (sample: Sample) => void): this { + this._onSampleStart = cb; + return this; + } + + public onSampleComplete(cb: (sample: BenchmarkSampleResult) => void): this { + this._onSampleComplete = cb; + return this; + } + + public run(): BenchmarkGroupResult[] { + const benchmarks = [] as BenchmarkGroupResult[]; + this._groups.forEach((group: BenchmarkGroup) => { + this._runGroup(benchmarks, group); + }); + return benchmarks; + } + + private _runGroup( + results: BenchmarkGroupResult[], + group: BenchmarkGroup + ): void { + const result = { + name: group.name, + samples: [] + } as BenchmarkGroupResult; + results.push(result); + + for (const sample of group.samples) { + const stats = new Stats(); + const name = sample.name ?? 'unnamed sample'; + const iterations = sample.iterations ?? 25; + this._onSampleStart({ ...sample, name, iterations }); + for (let i = 0; i < iterations; ++i) { + let context = {} as Context | null; + if (sample.setup) { + sample.setup(context as Context); + } + // @todo: add async. + if (global.gc) { + global.gc(); + } + stats.start(); + sample.code(context as Context); + stats.stop(); + context = null; + } + const sampleResult = { + name, + iterations, + average: stats.average, + memoryAverage: stats.memoryAverage + }; + this._onSampleComplete(sampleResult); + result.samples.push(sampleResult); + } + } +} + +export interface Context { + [key: string]: any; +} + +export interface Sample { + name?: string; + iterations?: number; + setup?: (context: Context) => void; + code: (context: Context) => void; +} + +export interface BenchmarkSampleResult { + name: string; + iterations: number; + average: number; + memoryAverage: number; +} + +export interface BenchmarkGroupResult { + name: string; + samples: BenchmarkSampleResult[]; +} diff --git a/benchmark/comparator.ts b/benchmark/comparator.ts new file mode 100644 index 0000000..9e739a9 --- /dev/null +++ b/benchmark/comparator.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; +import { BenchmarkGroupResult, BenchmarkSampleResult } from './benchmark'; + +export function compare( + sourceList: BenchmarkGroupResult[], + actualList: BenchmarkGroupResult[], + options: Partial = {} +): boolean { + let { memoryTolerance = 0.025, speedTolerance = 0.025 } = options; + + memoryTolerance += 1.0; + speedTolerance += 1.0; + + const actual = new Map() as Map; + for (const group of actualList) { + for (const sample of group.samples) { + actual.set(sample.name, sample); + } + } + + let success = true; + + for (const group of sourceList) { + log(chalk.bold(group.name)); + console.log(); + for (const srcSample of group.samples) { + const actualSample = actual.get(srcSample.name)!; + let speedDelta = 0; + let memDelta = 0; + if (actualSample.average > speedTolerance * srcSample.average) { + speedDelta = actualSample.average - srcSample.average; + } + if ( + actualSample.memoryAverage > + memoryTolerance * srcSample.memoryAverage + ) { + memDelta = actualSample.memoryAverage - srcSample.memoryAverage; + } + const passed = speedDelta <= 0.0001 && memDelta <= 0.0001; + if (passed) { + log(`✅ ${chalk.gray(actualSample.name)}`, 2); + } else { + log(`❌ ${chalk.red(actualSample.name)}`, 2); + } + if (speedDelta > 0) { + log(`${chalk.bold(speedDelta.toFixed(4))}ms slower`, 6); + } + if (memDelta > 0) { + log(`${chalk.bold(memDelta.toFixed(2))}ms slower`, 6); + } + success = success && passed; + } + } + + return success; +} + +function log(msg: string, spacing = 0): void { + console.log(`${' '.repeat(spacing)}${msg}`); +} + +interface ComparatorOptions { + memoryTolerance: number; + speedTolerance: number; +} diff --git a/benchmark/entity.bench.ts b/benchmark/entity.bench.ts new file mode 100644 index 0000000..bc328e9 --- /dev/null +++ b/benchmark/entity.bench.ts @@ -0,0 +1,169 @@ +import { Context, Benchmark } from './benchmark.js'; + +import { boolean, number, array, string, ref } from '../src/decorators.js'; +import { ComponentData, TagComponent } from '../src/component.js'; +import { World } from '../src/world.js'; +import { System } from '../src/system.js'; + +class MyTagComponent extends TagComponent {} + +class MyComponentData extends ComponentData { + @boolean(true) + myBoolean!: boolean; + @number(100) + myNumber!: number; + @string('hello') + myString!: string; + @array(['defaultStr1', 'defaultStr2']) + myArray!: string[]; + @ref(null) + myRef!: { foo: string; bar: string } | null; +} + +export default function (benchmark: Benchmark): void { + benchmark + .group('Entity') + .add({ + name: 'create / destroy entities without pool', + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: true }); + ctx.entities = new Array(100).fill(null); + }, + code: function (ctx: Context) { + const len = ctx.entities.length; + for (let i = 0; i < Math.floor(len / 3); ++i) { + ctx.entities[i] = ctx.world.create(); + } + for (let i = 0; i < Math.floor(len / 4); ++i) { + ctx.entities[i].destroy(); + ctx.entities[i] = null; + } + for (let i = 0; i < len; ++i) { + if (ctx.entities[i] === null) { + ctx.entities[i] = ctx.world.create(); + } + } + } + }) + .add({ + name: 'create / destroy entities with pool', + setup: function (ctx: Context) { + ctx.world = new World({ + useManualPooling: false + }); + ctx.entities = new Array(100); + }, + code: function (ctx: Context) { + const len = ctx.entities.length; + for (let i = 0; i < Math.floor(len / 3); ++i) { + ctx.entities[i] = ctx.world.create(); + } + for (let i = 0; i < Math.floor(len / 4); ++i) { + ctx.entities[i].destroy(); + ctx.entities[i] = null; + } + for (let i = 0; i < len; ++i) { + if (ctx.entities[i] === null) { + ctx.entities[i] = ctx.world.create(); + } + } + } + }) + .add({ + name: 'add tag component to entity - no pooling', + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: true }); + ctx.world.registerComponent(MyTagComponent); + ctx.entity = ctx.world.create(); + }, + code: function (ctx: Context) { + ctx.entity.add(MyTagComponent); + } + }) + .add({ + name: 'add tag component to entity - pooling', + iterations: 75, + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: false }); + ctx.world.registerComponent(MyTagComponent); + ctx.entity = ctx.world.create(); + }, + code: function (ctx: Context) { + ctx.entity.add(MyTagComponent); + } + }) + .add({ + name: 'remove tag component synchronously from entity', + iterations: 75, + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: true }); + ctx.world.registerComponent(MyTagComponent); + ctx.entity = ctx.world.create(); + ctx.entity.add(MyTagComponent); + }, + code: function (ctx: Context) { + ctx.entity.remove(MyTagComponent); + } + }) + .add({ + name: 'add data component to entity - no pooling', + iterations: 75, + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: true }); + ctx.world.registerComponent(MyComponentData); + ctx.entity = ctx.world.create(); + }, + code: function (ctx: Context) { + ctx.entity.add(MyComponentData, { + myBoolean: false, + myNumber: 1, + myString: 'Oh, Snap!', + myArray: [], + myRef: null + }); + } + }) + .add({ + name: 'add data component to entity - pooling', + iterations: 75, + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: false }); + ctx.world.registerComponent(MyComponentData); + ctx.entity = ctx.world.create(); + }, + code: function (ctx: Context) { + ctx.entity.add(MyComponentData, { + myBoolean: false, + myNumber: 1, + myString: 'Oh, Snap!', + myArray: [], + myRef: null + }); + } + }); + + (function () { + class MySystem extends System { + execute() { + /* Emnpty. */ + } + } + MySystem.Queries = {}; + for (let i = 0; i < 100000; ++i) { + MySystem.Queries[`query_${i}`] = [MyTagComponent]; + } + + benchmark.group('Entity').add({ + name: 'add tag component to entity - several queries', + setup: function (ctx: Context) { + ctx.world = new World({ useManualPooling: true }); + ctx.world.register(MySystem); + ctx.world.registerComponent(MyTagComponent); + ctx.entity = ctx.world.create(); + }, + code: function (ctx: Context) { + ctx.entity.add(MyTagComponent); + } + }); + })(); +} diff --git a/benchmark/index.ts b/benchmark/index.ts new file mode 100644 index 0000000..b83f46e --- /dev/null +++ b/benchmark/index.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import chalk from 'chalk'; + +import { promises as fsp } from 'fs'; + +import { + Benchmark, + BenchmarkGroupResult, + BenchmarkSampleResult +} from './benchmark.js'; +import { compare } from './comparator.js'; +import registerEntityBench from './entity.bench.js'; + +/** + * CLI argument parsing. + */ + +const argv = process.argv; +const args = { + output: null as string | null, + compare: null as string | null +}; + +const outputIndex = argv.findIndex((v) => v === '--output' || v === '-o'); +if (outputIndex >= 0 && outputIndex + 1 < process.argv.length) { + args.output = process.argv[outputIndex + 1]; +} +const compareIndex = argv.findIndex((v) => v === '--compare' || v === '-c'); +if (compareIndex >= 0 && compareIndex + 1 < process.argv.length) { + args.compare = process.argv[compareIndex + 1]; +} + +/** + * Main. + */ + +const benchmark = new Benchmark(); +benchmark.onSampleComplete((sample: BenchmarkSampleResult) => { + const avg = sample.average; + const avgStr = `${chalk.white.bold(sample.average.toFixed(4))}ms`; + if (avg > 3.0) { + console.log(`${chalk.red(sample.name)} ${avgStr}`); + } else if (avg > 1.0) { + console.log(`${chalk.yellow(sample.name)} ${avgStr}`); + } else if (avg > 0.15) { + console.log(`${chalk.gray(sample.name)} ${avgStr}`); + } else { + console.log(`${chalk.green(sample.name)} ${avgStr}`); + } +}); + +registerEntityBench(benchmark); + +console.log(); +console.log(chalk.white.bold(`--- starting benchmark ---`)); +console.log(); + +const benchmarks = benchmark.run(); +const promises = []; + +if (args.output !== null) { + const benchmarksJSON = JSON.stringify({ benchmarks }, null, 4); + const p = fsp + .writeFile(args.output, benchmarksJSON) + .then(() => 0) + .catch((e) => { + console.error(e); + return 1; + }); + promises.push(p); +} + +if (args.compare !== null) { + console.log(); + console.log(chalk.white.bold(`--- comparing to '${args.compare}' ---`)); + console.log(); + const p = fsp.readFile(args.compare as string, 'utf8').then((v: string) => { + const source = JSON.parse(v) as { benchmarks: BenchmarkGroupResult[] }; + const success = compare(source.benchmarks, benchmarks); + return success ? 0 : 1; + }); + promises.push(p); +} + +Promise.all(promises).then((exitCodes: number[]) => { + for (const code of exitCodes) { + if (code !== 0) { + process.exit(code); + } + } + console.log(); + if (args.output) { + console.log(chalk.white(`benchmark results written to '${args.output}'`)); + } +}); diff --git a/example/circle-boxes/index.html b/example/circle-boxes/index.html index 98c854a..a82a628 100644 --- a/example/circle-boxes/index.html +++ b/example/circle-boxes/index.html @@ -2,7 +2,7 @@ - Fecs example for simple drawing using the 2D Canvas API + Ecstra example for simple drawing using the 2D Canvas API diff --git a/example/circle-intersections/index.html b/example/circle-intersections/index.html index 27c282e..f7fb817 100644 --- a/example/circle-intersections/index.html +++ b/example/circle-intersections/index.html @@ -7,7 +7,7 @@ - Fecs example for simple drawing using the 2D Canvas API + Ecstra example for simple drawing using the 2D Canvas API diff --git a/example/typescript/circle-boxes/index.html b/example/typescript/circle-boxes/index.html new file mode 100644 index 0000000..f7137aa --- /dev/null +++ b/example/typescript/circle-boxes/index.html @@ -0,0 +1,33 @@ + + + + + TypeScript - Ecstra example for simple drawing using the 2D Canvas API + + + + + + +

+ Example taken and adapted from + ecsy.io +

+ + + + diff --git a/example/typescript/circle-boxes/index.ts b/example/typescript/circle-boxes/index.ts new file mode 100644 index 0000000..76b21ac --- /dev/null +++ b/example/typescript/circle-boxes/index.ts @@ -0,0 +1,170 @@ +import { + World, System, ComponentData, TagComponent, number, string, queries, after +} from '../../../dist/index.js'; + +const NUM_ELEMENTS = 600; +const SPEED_MULTIPLIER = 0.1; +const SHAPE_SIZE = 20; +const SHAPE_HALF_SIZE = SHAPE_SIZE / 2; + +const canvas = document.querySelector('canvas'); +const ctx = canvas.getContext('2d'); +let canvasWidth = canvas.width = window.innerWidth; +let canvasHeight = canvas.height = window.innerHeight; + +/** + * Components + */ + +class Velocity extends ComponentData { + @number() + x!: number; + @number() + y!: number; +} + +class Position extends ComponentData { + @number() + x!: number; + @number() + y!: number; +} + +class Shape extends ComponentData { + @string() + primitive!: string; +} + +class Renderable extends TagComponent {} + +/** + * Systems + */ + +@queries({ + // The `moving` query looks for entities with both the `Velocity` and + // `Position` components. + moving: [ Velocity, Position ] +}) +class MovableSystem extends System { + public execute(delta) { + this.queries.moving.execute(entity => { + const velocity = entity.read(Velocity); + const position = entity.write(Position); + position.x += velocity.x * delta; + position.y += velocity.y * delta; + if (position.x > canvasWidth + SHAPE_HALF_SIZE) position.x = - SHAPE_HALF_SIZE; + if (position.x < - SHAPE_HALF_SIZE) position.x = canvasWidth + SHAPE_HALF_SIZE; + if (position.y > canvasHeight + SHAPE_HALF_SIZE) position.y = - SHAPE_HALF_SIZE; + if (position.y < - SHAPE_HALF_SIZE) position.y = canvasHeight + SHAPE_HALF_SIZE; + }); + } +} + +@after([MovableSystem]) +class RendererSystem extends System { + + // This is equivalent to using the `query` decorator. + public static Queries = { + // The `renderables` query looks for entities with both the + // `Renderable` and `Shape` components. + renderables: [Renderable, Shape] + }; + + public execute(): void { + ctx.globalAlpha = 1; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + //ctx.globalAlpha = 0.6; + + // Iterate through all the entities on the query + this.queries.renderables.execute(entity => { + const shape = entity.read(Shape); + const position = entity.read(Position); + if (shape.primitive === 'box') { + this.drawBox(position); + } else { + this.drawCircle(position); + } + }); + } + + drawCircle(position) { + ctx.fillStyle = "#888"; + ctx.beginPath(); + ctx.arc(position.x, position.y, SHAPE_HALF_SIZE, 0, 2 * Math.PI, false); + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = "#222"; + ctx.stroke(); + } + + drawBox(position) { + ctx.beginPath(); + ctx.rect(position.x - SHAPE_HALF_SIZE, position.y - SHAPE_HALF_SIZE, SHAPE_SIZE, SHAPE_SIZE); + ctx.fillStyle= "#f28d89"; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = "#800904"; + ctx.stroke(); + } +} + +window.addEventListener( 'resize', () => { + canvasWidth = canvas.width = window.innerWidth + canvasHeight = canvas.height = window.innerHeight; +}, false ); + +// Step 1 - Create the world that will host our entities. +const world = new World() + .register(MovableSystem) + .register(RendererSystem); + +// Step 2 - Create entities with random velocity / positions / shapes +for (let i = 0; i < NUM_ELEMENTS; i++) { + world + .create() + .add(Velocity, getRandomVelocity()) + .add(Shape, getRandomShape()) + .add(Shape, getRandomShape()) + .add(Position, getRandomPosition()) + .add(Renderable) +} + +// Step 3 - Run all the systems and let the ECS do its job! +let lastTime = 0; +function run() { + // Compute delta and elapsed time. + const time = performance.now(); + const delta = time - lastTime; + + // Runs all the systems. + world.execute(delta); + + lastTime = time; + requestAnimationFrame(run); +} +lastTime = performance.now(); +run(); + +/** + * Set of helpers for component instanciation + */ + +function getRandomVelocity(): { x: number, y: number } { + return { + x: SPEED_MULTIPLIER * (2 * Math.random() - 1), + y: SPEED_MULTIPLIER * (2 * Math.random() - 1) + }; +} +function getRandomPosition(): { x: number, y: number } { + return { + x: Math.random() * canvasWidth, + y: Math.random() * canvasHeight + }; +} +function getRandomShape(): { primitive: string } { + return { + primitive: Math.random() >= 0.5 ? 'circle' : 'box' + }; +} diff --git a/package.json b/package.json index b049f30..66563a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ecstra", - "version": "0.0.1", + "version": "0.0.2", "description": "Fast & Flexible EntityComponentSystem (ECS) for JavaScript & TypeScript", "repository": { "type": "git", @@ -23,18 +23,23 @@ "build:umd": "rollup -c", "start": "npm run copy:to:dist && tsc --watch", "example": "http-server ./ -c-1 -p 8080 --cors ./example", + "example:build": "tsc ./example/typescript/**/*.ts --target ES6 --experimentalDecorators && npm run example", + "example:start": "tsc ./example/typescript/**/*.ts --target ES6 --experimentalDecorators -w && npm run example", + "benchmark": "node --expose-gc --loader ts-node/esm ./benchmark/index.ts", "lint": "eslint ./src/**/*.ts", "test": "ava", - "pretty": "prettier ./src/ ./test --write", + "pretty": "prettier ./src/ ./test ./benchmark --write", "copy:to:dist": "node --experimental-modules ./scripts/copy-to-dist.js" }, "devDependencies": { "@ava/typescript": "^1.1.1", "@rollup/plugin-node-resolve": "^11.1.1", "@rollup/plugin-replace": "^2.3.4", + "@types/node": "^14.14.28", "@typescript-eslint/eslint-plugin": "^4.13.0", "@typescript-eslint/parser": "^4.13.0", "ava": "^3.15.0", + "chalk": "^4.1.0", "eslint": "^7.17.0", "eslint-config-prettier": "^7.1.0", "eslint-plugin-prettier": "^3.3.1", diff --git a/src/entity.ts b/src/entity.ts index b74825b..3f8684b 100644 --- a/src/entity.ts +++ b/src/entity.ts @@ -32,35 +32,40 @@ export class Entity { /** * @hidden */ - private _world: World; + public readonly _components: Map; /** * @hidden */ - private readonly _id!: string; + public readonly _pendingComponents: Component[]; /** * @hidden */ - public readonly _components: Map; + public _archetype: Nullable>; /** * @hidden */ - public readonly _pendingComponents: Component[]; + public _indexInArchetype: number; /** * @hidden */ - private _archetype: Nullable>; + private _world!: World; + + /** + * @hidden + */ + private readonly _id!: string; - public constructor(world: World, name?: string) { + public constructor(name?: string) { this.name = name ?? null; this._id = createUUID(); this._components = new Map(); this._pendingComponents = []; - this._world = world; this._archetype = null; + this._indexInArchetype = -1; this._pooled = false; } diff --git a/src/index.ts b/src/index.ts index 676d95d..443c0be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ export { System } from './system.js'; export { SystemGroup } from './system-group.js'; export { World } from './world.js'; +export { ComponentRegisterOptions } from './internals/component-manager'; + /** Misc */ export { DefaultPool, ObjectPool } from './pool.js'; diff --git a/src/internals/archetype.ts b/src/internals/archetype.ts index f2b04da..f950d7f 100644 --- a/src/internals/archetype.ts +++ b/src/internals/archetype.ts @@ -23,4 +23,8 @@ export class Archetype { public get components(): Set { return this._components; } + + public get empty(): boolean { + return this.entities.length === 0; + } } diff --git a/src/internals/component-manager.ts b/src/internals/component-manager.ts index 358abc6..834d10d 100644 --- a/src/internals/component-manager.ts +++ b/src/internals/component-manager.ts @@ -1,11 +1,12 @@ import { Component, ComponentState, ComponentData } from '../component.js'; import { Entity } from '../entity.js'; -import { DefaultPool, ObjectPool } from '../pool.js'; +import { ObjectPool } from '../pool.js'; import { World } from '../world.js'; import { Archetype } from './archetype.js'; import { ComponentClass, ComponentOf, + Constructor, EntityOf, Nullable, Option, @@ -17,37 +18,36 @@ export class ComponentManager { public readonly maxComponentTypeCount: number; public readonly archetypes: Map>>; + private readonly _world: WorldType; private readonly _data: Map; + private readonly _DefaulPool: Nullable>>; - private readonly _useManualPooling: boolean; - - private readonly _emptyHash: string; + private readonly _emptyArchetype: Archetype>; private _lastIdentifier: number; public constructor(world: WorldType, options: ComponentManagerOptions) { - const { maxComponentType, useManualPooling } = options; + const { maxComponentType, ComponentPoolClass = null } = options; this.maxComponentTypeCount = maxComponentType; this._world = world; this.archetypes = new Map(); this._data = new Map(); - this._useManualPooling = useManualPooling; + this._DefaulPool = ComponentPoolClass; this._lastIdentifier = 0; - this._emptyHash = '0'.repeat(maxComponentType); - - this.archetypes.set(this._emptyHash, new Archetype([], this._emptyHash)); + this._emptyArchetype = new Archetype([], '0'.repeat(maxComponentType)); + this.archetypes.set(this._emptyArchetype.hash, this._emptyArchetype); } public initEntity(entity: EntityOf): void { - const archetype = this.archetypes.get(this._emptyHash)!; - archetype.entities.push(entity); + this._emptyArchetype.entities.push(entity); + entity._archetype = this._emptyArchetype; } public destroyEntity(entity: Entity): void { const archetype = entity.archetype; if (archetype) { archetype.entities.splice(archetype.entities.indexOf(entity), 1); - entity['_archetype'] = null; + entity._archetype = null; // @todo: that may not be really efficient if an archetype is always // composed of one entity getting attached / dettached. if (archetype.entities.length === 0) { @@ -81,7 +81,7 @@ export class ComponentManager { } comp._state = ComponentState.Ready; // @todo: check in dev mode for duplicate. - entity['_components'].set(Class, comp); + entity._components.set(Class, comp); this.updateArchetype(entity, Class); } @@ -98,16 +98,35 @@ export class ComponentManager { return this.registerComponent(Class).identifier; } + public registerComponentManual( + Class: ComponentClass, + opts?: ComponentRegisterOptions + ): void { + if (process.env.NODE_ENV === 'development') { + if (this._data.has(Class)) { + const name = Class.Name ?? Class.name; + console.warn(`component ${name} is already registered`); + } + } + if (this._lastIdentifier >= this.maxComponentTypeCount) { + throw new Error('reached maximum number of components registered.'); + } + const identifier = this._lastIdentifier++; + let pool = null as Nullable>; + if (opts && opts.pool) { + pool = opts.pool; + } else if (this._DefaulPool) { + pool = new this._DefaulPool(Class); + pool.expand(1); + } + this._data.set(Class, { identifier, pool }); + } + public registerComponent(Class: ComponentClass): ComponentCache { if (!this._data.has(Class)) { - if (this._lastIdentifier >= this.maxComponentTypeCount) { - throw new Error('reached maximum number of components registered.'); - } - const identifier = this._lastIdentifier++; - const pool = !this._useManualPooling ? new DefaultPool(Class) : null; - this._data.set(Class, { identifier, pool }); + this.registerComponentManual(Class); } - return this._data.get(Class)!; + return this._data.get(Class) as ComponentCache; } public updateArchetype( @@ -168,26 +187,40 @@ export class ComponentManager { if (!this.archetypes.has(hash)) { const classes = entity.componentClasses; const archetype = new Archetype>(classes, hash); - this.archetypes.set(hash, archetype); + this.archetypes.set(archetype.hash, archetype); this._world._onArchetypeCreated(archetype); } - const archetype = this.archetypes.get(hash)!; - archetype.entities.push(entity); - entity['_archetype'] = archetype; + const archetype = this.archetypes.get(hash) as Archetype< + EntityOf + >; + const entities = archetype.entities; + entity._indexInArchetype = entities.length; + entity._archetype = archetype; + entities.push(entity); } private _removeEntityFromArchetype(entity: EntityOf): void { - const archetype = entity.archetype; - if (archetype) { - entity['_archetype'] = null; - // Removes from previous archetype - archetype.entities.splice(archetype.entities.indexOf(entity), 1); - // @todo: that may not be really efficient if an archetype is always - // composed of one entity getting attached / dettached. - if (archetype.entities.length === 0) { - this.archetypes.delete(archetype.hash); - this._world._onArchetypeDestroyed(archetype); - } + const archetype = entity.archetype as Archetype>; + const entities = archetype.entities; + + // Move last entity to removed location. + if (entities.length > 1) { + const last = entities[entities.length - 1]; + last._indexInArchetype = entity._indexInArchetype; + entities[entity._indexInArchetype] = last; + entities.pop(); + } else { + entities.length = 0; + } + + entity._archetype = null; + entity._indexInArchetype = -1; + + // @todo: that may not be really efficient if an archetype is always + // composed of one entity getting attached / dettached. + if (archetype !== this._emptyArchetype && archetype.empty) { + this.archetypes.delete(archetype.hash); + this._world._onArchetypeDestroyed(archetype); } } @@ -196,16 +229,22 @@ export class ComponentManager { Class: ComponentClass, added: boolean ): string { - const index = this._world['_components'].getIdentifier(Class); + const index = this.getIdentifier(Class); const entry = added ? '1' : '0'; - const hash = entity.archetype ? entity.archetype.hash : this._emptyHash; - return `${hash.substring(0, index)}${entry}${hash.substring(index + 1)}`; + const arch = entity.archetype!; + return `${arch.hash.substring(0, index)}${entry}${arch.hash.substring( + index + 1 + )}`; } } +export interface ComponentRegisterOptions { + pool?: ObjectPool; +} + export type ComponentManagerOptions = { maxComponentType: number; - useManualPooling: boolean; + ComponentPoolClass: Nullable>>; }; type ComponentCache = { diff --git a/src/internals/system-manager.ts b/src/internals/system-manager.ts index 9080c9f..45194ed 100644 --- a/src/internals/system-manager.ts +++ b/src/internals/system-manager.ts @@ -50,6 +50,7 @@ export class SystemManager { groupInstance.add(system); groupInstance.sort(); this._systems.set(Class, system); + if (system.init) system.init(); return this; } @@ -74,6 +75,7 @@ export class SystemManager { // somewhere else. this._groups.splice(this._groups.indexOf(group), 1); } + if (system.dispose) system.dispose(); // Deletes system entry. this._systems.delete(Class); } diff --git a/src/system.ts b/src/system.ts index da349fa..ff86271 100644 --- a/src/system.ts +++ b/src/system.ts @@ -142,6 +142,16 @@ export abstract class System { */ public abstract execute(delta: number): void; + /** + * Called when the System is registered in a World + */ + public init?(): void; + + /** + * Called when the System is removed from a World + */ + public dispose?(): void; + /** Returns the group in which this system belongs */ public get group(): SystemGroup { return this._group; diff --git a/src/types.ts b/src/types.ts index 017b95e..ddca08c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { Component, ComponentData, Properties } from './component'; +import { Entity } from './entity'; import { ObjectPool } from './pool'; import { Property } from './property'; import { StaticQueries, System } from './system'; @@ -20,6 +21,8 @@ export type PropertiesOf = Partial< export type Constructor = new (...args: any[]) => T; +export type EntityClass = new (name?: string) => T; + /** Class type for a SystemGroup derived type */ export type SystemGroupClass< T extends SystemGroup = SystemGroup diff --git a/src/world.ts b/src/world.ts index 15e3084..cc0c854 100644 --- a/src/world.ts +++ b/src/world.ts @@ -1,5 +1,8 @@ import { Entity } from './entity.js'; -import { ComponentManager } from './internals/component-manager.js'; +import { + ComponentManager, + ComponentRegisterOptions +} from './internals/component-manager.js'; import { QueryManager } from './internals/query-manager.js'; import { SystemManager } from './internals/system-manager.js'; import { System } from './system.js'; @@ -12,6 +15,7 @@ import { ComponentOf, Constructor, EntityOf, + EntityClass, Nullable, Option, PropertiesOf, @@ -82,19 +86,34 @@ export class World { public constructor(options: Partial> = {}) { const { maxComponentType = 256, - useManualPooling = true, - EntityClass = Entity + useManualPooling = false, + EntityClass = Entity, + EntityPoolClass = DefaultPool, + ComponentPoolClass = DefaultPool, + systems = [], + components = [] } = options; this._queries = new QueryManager(this); this._systems = new SystemManager(this); this._components = new ComponentManager(this, { maxComponentType, - useManualPooling + ComponentPoolClass: useManualPooling ? null : ComponentPoolClass }); this._EntityClass = EntityClass as EntityClass>; - this._entityPool = !useManualPooling - ? new DefaultPool(this._EntityClass) - : null; + + this._entityPool = null; + if (useManualPooling) { + this._entityPool = new EntityPoolClass( + this._EntityClass + ) as EntityPool; + } + + for (const component of components) { + this.registerComponent(component); + } + for (const system of systems) { + this.register(system as SystemClass>); + } } /** @@ -133,6 +152,32 @@ export class World { return this; } + /** + * Registers a component type in this world instance. + * + * ## Notes + * + * It's not mandatory to pre-register a component this way. However, it's + * always better to pre-allocate and initialize everything you can on startup + * for optimal performance at runtime. + * + * Registering a component manually will avoid registration on first usage + * and can thus optimize your runtime performance. + * + * @param Class - Class of the component to register + * @param opts - Set of options to affect the component registration, such + * as the pool used + * + * @return This instance + */ + public registerComponent( + Class: ComponentClass, + opts?: ComponentRegisterOptions + ): this { + this._components.registerComponentManual(Class, opts); + return this; + } + /** * Creates a new entity * @@ -145,12 +190,12 @@ export class World { let entity; if (this._entityPool) { entity = this._entityPool.acquire(); - entity['_world'] = this; entity._pooled = true; entity.name = name ?? null; } else { - entity = new this._EntityClass(this, name); + entity = new this._EntityClass(name); } + entity['_world'] = this; this._components.initEntity(entity); return entity; } @@ -346,12 +391,86 @@ export class World { } } +/** + * Options for the [[World]] constructor + */ export interface WorldOptions { + /** Default list of systems to register. */ + systems: SystemClass[]; + + /** Default list of components to register. */ components: ComponentClass[]; + + /** + * Number of components that will be registered. + * + * This is used for performance reasons. It's preferable to give the exact + * amount of component type you are going to use, but it's OK to give an + * inflated number if you don't fully know in advanced all components that + * will be used. + * + * Default: 256 + */ maxComponentType: number; + + /** + * If `true`, no pool is created by default for components and entities. + * + * Default: `false` + */ useManualPooling: boolean; + + /** + * Class of entity to instanciate when calling `world.create()`. + * + * **Note**: if you use your own entity class, please make sure it's + * compatible with the default entity pool (if not using a custom pool). Try + * to keep the same interface (constructor, methods, etc...) + * + * Default: [[Entity]] + */ EntityClass: EntityClass; + + /** + * Class of the default pool that will be used for components. + * + * Using you custom default pool allow you to perform fine-tuned logic to + * improve pooling performance. + * + * ## Notes + * + * The pool will be instanciated by the world using: + * + * ```js + * const pool = new ComponentPoolClass(ComponentType); + * ``` + * + * Please ensure that your interface is compatible + * + * Default: [[DefaultPool]] + */ + ComponentPoolClass: Constructor>; + + /** + * Class of the default pool that will be used for entities. + * + * Using you custom default pool allow you to perform fine-tuned logic to + * improve pooling performance. + * + * ## Notes + * + * The pool will be instanciated by the world using: + * + * ```js + * const pool = new EntityPoolClass(EntityClass); + * ``` + * + * Please ensure that your interface is compatible + * + * Default: [[DefaultPool]] + */ + EntityPoolClass: Constructor>; } export interface SystemRegisterOptions { @@ -359,6 +478,4 @@ export interface SystemRegisterOptions { order?: number; } -export type EntityClass = Constructor; - type EntityPool = ObjectPool>; diff --git a/test/unit/component.test.ts b/test/unit/component.test.ts index 9af16da..9c3c629 100644 --- a/test/unit/component.test.ts +++ b/test/unit/component.test.ts @@ -34,7 +34,6 @@ test('Component > ComponentData > Properties created', (t) => { entity = world.create(); entity.add(TestComponentDecorator); const compDecorator = entity.read(TestComponentDecorator)!; - console.log(compDecorator); t.true(compDecorator.myBoolean !== undefined); }); diff --git a/test/unit/entity.test.ts b/test/unit/entity.test.ts index 670219c..54ca03c 100644 --- a/test/unit/entity.test.ts +++ b/test/unit/entity.test.ts @@ -18,7 +18,7 @@ test('Entity - create entity default', (t) => { test('Entity - add component', (t) => { const world = new World(); const entity = world.create(); - t.is(entity['_archetype'], null); + t.is(entity['_archetype'], world['_components']['_emptyArchetype']); t.true(entity.isEmpty); t.deepEqual(entity.componentClasses, []); diff --git a/test/unit/utils.ts b/test/unit/utils.ts index e93149e..bb98e4a 100644 --- a/test/unit/utils.ts +++ b/test/unit/utils.ts @@ -32,3 +32,15 @@ export class FooBarSystem extends System { }; execute() {} } + +export function spy() { + function proxy(...args: unknown[]) { + proxy.calls.push(args); + proxy.called = true; + } + + proxy.calls = [] as unknown[]; + proxy.called = false; + + return proxy; +} diff --git a/test/unit/world.test.ts b/test/unit/world.test.ts index 03a601c..7331800 100644 --- a/test/unit/world.test.ts +++ b/test/unit/world.test.ts @@ -1,12 +1,13 @@ import test from 'ava'; import { SystemGroup } from '../../src/system-group.js'; import { System } from '../../src/system.js'; - import { World } from '../../src/world.js'; +import { spy } from './utils.js'; test('World > System > register', (t) => { class MySystem extends System { execute() {} + init = spy(); } const world = new World(); world.register(MySystem); @@ -14,6 +15,7 @@ test('World > System > register', (t) => { t.true(!!sys); t.is(sys.group.constructor, SystemGroup); + t.true(sys.init.called); }); test('World > System > register with group', (t) => { @@ -56,6 +58,7 @@ test('World > SystemGroup > retrieve', (t) => { test('World > System > unregister', (t) => { class MySystem extends System { execute() {} + dispose = spy(); } class MyGroup extends SystemGroup {} @@ -72,4 +75,5 @@ test('World > System > unregister', (t) => { t.false(!!world.system(MySystem)!); t.is(group['_systems'].indexOf(system), -1); t.is(world.group(MyGroup), undefined); + t.true(system.dispose.called); }); diff --git a/tsconfig.json b/tsconfig.json index 52be36f..57b79c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "stripInternal": true, + "allowSyntheticDefaultImports": true, "experimentalDecorators": true }, diff --git a/yarn.lock b/yarn.lock index a008199..6f3d221 100644 --- a/yarn.lock +++ b/yarn.lock @@ -130,6 +130,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.25.tgz#15967a7b577ff81383f9b888aa6705d43fbbae93" integrity sha512-EPpXLOVqDvisVxtlbvzfyqSsFeQxltFbluZNRndIb8tr9KiBnYNLzrc1N3pyKUCww2RNrfHDViqDWWE1LCJQtQ== +"@types/node@^14.14.28": + version "14.14.28" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.28.tgz#cade4b64f8438f588951a6b35843ce536853f25b" + integrity sha512-lg55ArB+ZiHHbBBttLpzD07akz0QPrZgUODNakeC09i62dnrywr9mFErHuaPlB6I7z+sEbK+IYmplahvplCj2g== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"