From f6b654cffa848704af10e8256ceb56a8b0d3bc1f Mon Sep 17 00:00:00 2001 From: Noah Lange Date: Wed, 7 Feb 2024 14:05:47 -0600 Subject: [PATCH] Improves test coverage, pares down README. --- .prettierignore | 1 + README.md | 198 ++++-------------- benchmarks/queries/building.ts | 4 +- benchmarks/serialization/index.js | 2 - benchmarks/serialization/index.ts | 2 + benchmarks/serialization/{load.js => load.ts} | 11 +- benchmarks/serialization/{save.js => save.ts} | 7 +- package.json | 4 +- src/lib/QueryBuilder.ts | 4 +- src/lib/Registry.test.ts | 9 +- src/lib/Registry.ts | 3 +- src/lib/Serializer.test.ts | 43 +++- src/test/helpers/components.ts | 2 +- src/test/helpers/entities.ts | 5 +- src/types.ts | 5 +- 15 files changed, 115 insertions(+), 185 deletions(-) create mode 100644 .prettierignore delete mode 100644 benchmarks/serialization/index.js create mode 100644 benchmarks/serialization/index.ts rename benchmarks/serialization/{load.js => load.ts} (65%) rename benchmarks/serialization/{save.js => save.ts} (65%) diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..42061c0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/README.md b/README.md index 7869b1c..29211d9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![CodeQL](https://github.com/noahlange/gecs/actions/workflows/codeql-analysis.yml/badge.svg) [![Code Coverage](https://coveralls.io/repos/github/noahlange/gecs/badge.svg?branch=master)](https://coveralls.io/github/noahlange/gecs?branch=master) -**gecs** ('g' as in 'gecko,' not as in 'GIF') is an experimental, generic-abusing [entity-component-system](https://en.wikipedia.org/wiki/Entity_component_system) framework thing written in [TypeScript](https://www.typescriptlang.org). +**gecs** ('g' as in 'gecko,' not 'GIF') is an experimental, generic-abusing [entity-component-system](https://en.wikipedia.org/wiki/Entity_component_system) framework thing written in [TypeScript](https://www.typescriptlang.org). Examples are available in the [gecs-example](https://github.com/noahlange/gecs-example) repository. @@ -31,76 +31,40 @@ myContext.$.state instanceof StatePlugin; // true The systems, entities, components and tags provided by a plugin are automatically registered when the Context's `start` method is invoked. ```typescript -import { Plugin, Phase, phase } from 'gecs'; -import type { PluginData } from 'gecs'; - -import { A, B, C } from './components'; -import { WithA, WithB, WithC } from './entities'; - -// a pain—I'm trying to figure out a way to avoid having -// to annotate both the Plugin and the PluginData -interface ContextPlugins { - state: StatePlugin; +import { Plugin, type PluginData } from 'gecs'; +import { Position, Velocity, Collision } from './components'; +import { Collider } from './entities'; +import { myPhysicsSystem, MyOtherSystem } from './systems' + +// you can decompose plugins across multiple packages with declaration merging +declare global { + namespace $ { + interface Plugins { + [PhysicsSystem.type]: PhysicsSystem; + } + } } -export default class StatePlugin extends Plugin { - public static readonly type = 'state'; +export default class PhysicsSystem extends Plugin<$.Plugins> { + public static readonly type = 'physics'; // entities, components, tags and systems to register on start - public $: PluginData = { - components: { A, B, C }, - entities: { WithA, WithB, WithC }, + public $: PluginData<$.Plugins> = { + components: { Position, Velocity, Collision }, + entities: { Collider }, // arbitrary string tags tags: ['one', 'two', 'three'], // systems; either stateless function systems or stateful, class-based - systems: [ - phase(Phase.ON_LOAD, ctx => { - // to be executed at the beginning of the tick - }), - phase(Phase.ON_UPDATE, ctx => { - // to be executed during the update phase - }) - ] + systems: [myPhysicsSystem, MyOtherSystem] }; -} -``` - -### Phases -By specifying a static (numeric) `phase` property on a system, or using the `phase()` system composition helper, you can group systems together into different portions of the tick. Ties between systems in different plugins are executed in order of plugin registration. Systems without an explicit phase are executed at the end of the `UPDATE` phase (`599`). - -```typescript -import { Phase as DefaultPhases, phase } from 'gecs'; - -export const Phase = { ...DefaultPhases, MY_CUSTOM_PHASE: 299 }; - -export default phase( - Phase.MY_CUSTOM_PHASE, - ctx => { - // to be executed during my custom phase - }, - ctx => { - // to be executed after the previous system + // you can use the plugin to "host" commonly-used queries + public readonly queries = { + movers: this.ctx.query.components(Position, Velocity) } -); +} ``` - -There are three main phases—`LOAD`, `UPDATE` and `RENDER`—each broken into `PRE`, `ON` and `POST` sub-phases. - -| Phase | Priority | Description | -| :------------ | :------: | :----------------------------------------- | -| `PRE_LOAD` | 100 | perform setup, clean-up from previous tick | -| `ON_LOAD` | 200 | load data, input | -| `POST_LOAD` | 300 | post-process input | -| `PRE_UPDATE` | 400 | prepare game logic | -| `ON_UPDATE` | 500 | execute game logic | -| `POST_UPDATE` | 600 | apply necessary corrections | -| `PRE_RENDER` | 700 | prepare for rendering | -| `ON_RENDER` | 800 | render | -| `POST_RENDER` | 900 | clean up, tear down | - -When the context's (async) `start()` method is invoked, each of the context's systems is booted in the order it was passed to `with()`. Each time `tick()` is called, the context invokes the `tick()` method of each of its systems (again, in order). - + ## Entities & Components An Entity is a loose wrapper around an arbitrary collection of Components. @@ -153,7 +117,7 @@ entity.$.woobly.value === 1; // true // You can pass a data param to populate a component's instance properties. const entity2 = ctx.create(MyEntity, { foobly: { value: '123' } }); -entity2.$.foobly instanceof Foo; // true +entity2.$.foobly instanceof Foo; // true entity2.$.foobly.value === '123'; // true ``` @@ -172,9 +136,9 @@ export class Ownership extends EntityRef { const owner = ctx.create(Actor); const item = ctx.create(Item); -item.$.owner === null; // true; refs default to null -item.$.owner = owner; // refs are assigned like properties -item.$.owner instanceof Actor; // true +item.$.owner === null; // true; refs default to null +item.$.owner = owner; // refs are assigned like properties +item.$.owner instanceof Actor; // true // you can pass an entity as the value of the corresponding key in `ctx.create()` const item2 = ctx.create(Item, { owner }); @@ -185,23 +149,16 @@ Per the example above, you can `extend` the result of the `with()` call to creat ```typescript // composition const MyEntity1 = Entity.with(Position, Sprite); +type InstanceMyEntity1 = InstanceType; // inheritance class MyEntity2 extends Entity.with(Position, Sprite) {} +type InstanceMyEntity2 = MyEntity2; ``` This is a trade-off; while the first ("composition") is terser and discourages the addition of custom functionality to your entities, typing its instances is slightly more obnoxious. -The second ("inheritance") gives you more flexibility, as well as a lengthy rope to hang yourself with. - -```typescript -// composition -type InstanceMyEntity1 = InstanceType; -// inheritance -type InstanceMyEntity2 = MyEntity2; -``` - -You may need to hint an entity's type without a concrete instance on hand (e.g. in the case of function parameters). +You may need to hint an entity's type without a concrete instance on hand (e.g. in the case of function parameters)—you can use `EntityType` to do this. ```typescript import { SpritePosition } from '../entities'; @@ -242,7 +199,8 @@ An entity's components and tags can be added/removed using the `.components` and entity.components.add(ComponentA, aData); entity.components.has(A, B, C); entity.components.remove(D, E, F); -entity.components.all(); // same as Array.from(entity.components) +entity.components.all(); +[...entity.components]; // equivalent to .all() for (const component of entity.components) { // do stuff @@ -306,7 +264,7 @@ import type { Context } from 'gecs'; import { Position, Velocity } from './components'; export function movement(ctx: Context): void { - for (const { $ } of ctx.query.components(Position, Velocity)) { + for (const { $ } of ctx.$.physics.queries.movers) { $.position.x += $.velocity.dx; $.position.y += $.velocity.dy; } @@ -418,8 +376,8 @@ const q2 = ctx.query.tags('one', 'two', 'three'); // `.references()` returns all entities with an EntityRef pointing to the passed entity instance const q3 = ctx.query - .components(RefComponent) - .references(referencedEntity); + .components(RefComponent) + .references(referencedEntity); ``` Steps are executed sequentially. The result of a query is the intersection of each step's results. @@ -460,11 +418,11 @@ You can invoke a query's `first()` or `get()` methods to access its result set. const query = ctx.query.components(A, B); // instance methods - query executed -const all = query.get(); // (A & B)[] +const all = query.get(); // (A & B)[] const first = query.first(); // (A & B) | null // will work with sets, etc. -const set = new Set(query); // Set +const set = new Set(query); // Set // also as a generic iterable for (const { $ } of query) { @@ -478,38 +436,6 @@ Once a query is executed for the first time, any subsequent query with the same This means that overhead associated with creating a new query each `tick()` is _relatively_ minor—but by assigning the query to a variable/class property, you can access and execute the constructed query without being forced to rebuild it. -```typescript -class MySystem extends System { - public $ = { - abc: this.ctx.query.components(A, B, C) - }; - - public tick() { - for (const abc of this.$.abc) { - // ... - } - } -} -``` - -When using stateless systems, plugin instances can also be used to "host" persisted queries. - -```typescript -import { Plugin } from'gecs'; - -class MyPlugin extends Plugin { - public queries = { - abc: this.ctx.components(A, B, C) - } -} - -function MySystem(ctx: Context) { - for (const { $ } of ctx.$.myPlugin.queries.abc) { - // ... - } -} -``` - ## Saving & Loading Being able to export the game state to a serializable format and reloading it later is important. And since that is the case, it's also intended to be pretty straightforward. The output is a bulky POJO—in a purely naïve dump, ~2000 entities runs me about 650 KB. There are a number of strategies you can use to reduce the size of this output: entity filtering, custom component serialization and output compression. @@ -517,11 +443,7 @@ Being able to export the game state to a serializable format and reloading it la ### Entity filtering Filter entities by passing `ctx.save()` an `entityFilter` option—a predicate passed the entity instance and expecting a boolean-ish return value. This allows you to immediately weed out irrelevant entities before moving forward, which will significantly reduce the size of your result set (and save time). - -### Custom serialization - -You can write custom `toJSON()` methods to return only a subset of each component's data. - + ### Save ```typescript @@ -541,42 +463,6 @@ console.log(state === ctx.state); // true console.log(entities.some(e => e.tags.includes(Tag.TO_SERIALIZE))); // false ``` -#### Custom serialization - -If you're using `JSON.stringify` to serialize your state, you can customize a component's output by adding a `toJSON()` method. You can pair this with a setter to populate or manipulate a component's "exotic" properties on instantiation. - -```ts -interface HealthState { - value: number; - max: number; -} - -class Health extends Component { - public health = new MyHealth(100); - - // return "$" on save... - public toJSON(): HealthState { - return { - $: { - value: this.health.value, - max: this.health.max - } - }; - } - - // ...set via "$" on load - public set $(value: HealthState) { - this.health.doSomethingSpecial($.value, $.max); - } -} -``` - -#### Compression - -Compressing the original 650KB payload output with [Brotli](https://www.npmjs.com/package/brotli) brings it down to less than 20 KB (about 3% of the original size). - -If you're working in the browser and can't load WebAssembly for one reason or another, [pako](https://github.com/nodeca/pako) is a great, marginally less effective (about 4% of the original size) alternative. - ### Load Serialization has one caveat: you must manually register all components types and entity constructors using `extends` before invoking `ctx.load()`. Composed entity classes don't need to be registered. @@ -590,7 +476,11 @@ const ctx = new Context(); // you must register components and entity constructors using inheritance // (composed entity constructors don't need to be registered) -ctx.register(Object.values(Components), Object.values(Entities), Object.values(Tags)); +ctx.register( + Object.values(Components), + Object.values(Entities), + Object.values(Tags) +); // fetch and load state await fetch('./save.json') diff --git a/benchmarks/queries/building.ts b/benchmarks/queries/building.ts index 648c36d..adad6c5 100644 --- a/benchmarks/queries/building.ts +++ b/benchmarks/queries/building.ts @@ -1,4 +1,6 @@ -import { Context, EntityClass } from 'gecs'; +import type { EntityClass } from 'gecs'; + +import { Context } from 'gecs'; import bench from 'nanobench'; import { Test1, Test2, Test3 } from '../helpers/components'; diff --git a/benchmarks/serialization/index.js b/benchmarks/serialization/index.js deleted file mode 100644 index f72bbcd..0000000 --- a/benchmarks/serialization/index.js +++ /dev/null @@ -1,2 +0,0 @@ -require('./load'); -require('./save'); diff --git a/benchmarks/serialization/index.ts b/benchmarks/serialization/index.ts new file mode 100644 index 0000000..7fa3d8e --- /dev/null +++ b/benchmarks/serialization/index.ts @@ -0,0 +1,2 @@ +import './load'; +import './save'; diff --git a/benchmarks/serialization/load.js b/benchmarks/serialization/load.ts similarity index 65% rename from benchmarks/serialization/load.js rename to benchmarks/serialization/load.ts index 1d8db0c..c8e25e0 100644 --- a/benchmarks/serialization/load.js +++ b/benchmarks/serialization/load.ts @@ -1,17 +1,18 @@ -const bench = require('nanobench'); -const { Context } = require('gecs'); -const { setup } = require('../helpers/serialization'); +import { Context } from 'gecs'; +import bench from 'nanobench'; + +import { setup } from '../helpers/serialization'; for (const count of [1, 10, 50, 100]) { for (const cmp of [1, 2, 3]) { bench(`load ${count}k entities (${cmp} component)`, b => { const ctx = setup(count, cmp); - const saved = ctx.serialize(); + const saved = ctx.save(); const ctx2 = new Context(); ctx2.register(require('../helpers/entities'), require('../helpers/components'), require('../helpers/tags')); b.start(); - ctx2.deserialize(saved); + ctx2.load(saved); b.end(); }); } diff --git a/benchmarks/serialization/save.js b/benchmarks/serialization/save.ts similarity index 65% rename from benchmarks/serialization/save.js rename to benchmarks/serialization/save.ts index f41201a..077229c 100644 --- a/benchmarks/serialization/save.js +++ b/benchmarks/serialization/save.ts @@ -1,12 +1,13 @@ -const bench = require('nanobench'); -const { setup } = require('../helpers/serialization'); +import bench from 'nanobench'; + +import { setup } from '../helpers/serialization'; for (const count of [1, 10, 50, 100]) { for (const cmp of [1, 2, 3]) { bench(`save ${count}k entities (${cmp} component)`, b => { const ctx = setup(count, cmp); b.start(); - ctx.serialize(); + ctx.save(); b.end(); }); } diff --git a/package.json b/package.json index caade05..db22960 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,7 @@ "url": "https://noahlange.com" }, "type": "module", - "module": "esm/index.js", - "main": "lib/index.js", - "source": "src/index.ts", + "main": "src/index.ts", "types": "types/index.d.ts", "devDependencies": { "@types/nanobench": "^3.0.0", diff --git a/src/lib/QueryBuilder.ts b/src/lib/QueryBuilder.ts index 962f104..8c85061 100644 --- a/src/lib/QueryBuilder.ts +++ b/src/lib/QueryBuilder.ts @@ -91,9 +91,9 @@ export class QueryBuilder implements QueryBuilderBase(...components: A): QueryBuilder>> { + public components(...components: A): QueryBuilder> { this.state.ids.push(...components.map(c => c.type)); - return this.reset() as unknown as QueryBuilder>>; + return this.reset() as unknown as QueryBuilder>; } /** diff --git a/src/lib/Registry.test.ts b/src/lib/Registry.test.ts index 71c54a4..c833438 100644 --- a/src/lib/Registry.test.ts +++ b/src/lib/Registry.test.ts @@ -13,8 +13,15 @@ describe('Registry', () => { test('getID', () => { const reg = new Registry(); reg.add('0', '1'); - expect(reg.getID('1')?.toString()).toBe('4'); + expect(reg.getID('1')).toBe(4n); expect(reg.getID('foobar')).toBeNull(); }); }); + + test('release() makes the key associated with an identifier available for reuse', () => { + const reg = new Registry(); + reg.add('0', '1', '2'); // 2n, 4n, 8n + reg.release('0'); + expect(reg.add('3')).toBe(2n); + }); }); diff --git a/src/lib/Registry.ts b/src/lib/Registry.ts index 2515fc2..14266c4 100644 --- a/src/lib/Registry.ts +++ b/src/lib/Registry.ts @@ -23,7 +23,8 @@ export class Registry { return res === 0n ? null : res; } - public release(id: bigint): void { + public release(key: Identifier): void { + const id = this.registry[key]; this.generator.release(id); } } diff --git a/src/lib/Serializer.test.ts b/src/lib/Serializer.test.ts index a093b52..8b10fec 100644 --- a/src/lib/Serializer.test.ts +++ b/src/lib/Serializer.test.ts @@ -9,16 +9,18 @@ import * as C from '../test/helpers/components'; import * as E from '../test/helpers/entities'; describe('save and load', () => { - test("basics: doesn't explode", () => { + test("basics: doesn't explode", async () => { const ctx = getContext(); - for (let i = 0; i < 5; i++) { - ctx.create(E.WithA); - ctx.create(E.cWithAB); - ctx.create(E.WithRef); - } + await withTick(ctx, () => { + for (let i = 0; i < 5; i++) { + ctx.create(E.WithA); + ctx.create(E.cWithAB); + ctx.create(E.WithRef); + ctx.create(E.WithD, { d: { value: [null] } }); + } + }); let res: Serialized; - expect(() => (res = ctx.save())).not.toThrow(); expect(() => ctx.load(res)).not.toThrow(); }); @@ -59,6 +61,33 @@ describe('save and load', () => { const save2 = ctx2.save(); expect(save1).toEqual(save2); }); + + test('toJSON() can be used to customize serialization behavior', async () => { + class Custom extends Component { + public static readonly type = 'custom'; + public value: string = '???'; + public set $(value: string) { + this.value = value; + } + public toJSON() { + return { $: this.value.toUpperCase() }; + } + } + + const [ctx1, ctx2] = [getContext(), getContext()]; + + ctx1.register([], [Custom]), ctx2.register([], [Custom]); + + await withTick(ctx1, () => ctx1.create(Entity.with(Custom), { custom: { value: 'def' } })); + + const saved = ctx1.save(); + + await withTick(ctx2, () => ctx2.load(saved)); + + const a = ctx2.query.components(Custom).first(); + + expect(a!.$.custom.value).toBe('DEF'); + }); }); test('serialize null refs', async () => { diff --git a/src/test/helpers/components.ts b/src/test/helpers/components.ts index 78af52c..60a6244 100644 --- a/src/test/helpers/components.ts +++ b/src/test/helpers/components.ts @@ -21,7 +21,7 @@ export class C extends Component { export class D extends Component { public static readonly type = 'd'; - public value: bigint = 0n; + public value: (object | null)[] = []; } export class Ref extends EntityRef { diff --git a/src/test/helpers/entities.ts b/src/test/helpers/entities.ts index d45d8b7..3e47162 100644 --- a/src/test/helpers/entities.ts +++ b/src/test/helpers/entities.ts @@ -1,6 +1,6 @@ /* eslint-disable max-classes-per-file */ import { Entity } from '../../'; -import { A, B, C, Ref } from './components'; +import { A, B, C, D, Ref } from './components'; // object entities class WithA extends Entity.with(A) {} @@ -10,6 +10,7 @@ class WithAB extends Entity.with(A, B) {} class WithAC extends Entity.with(A, C) {} class WithABC extends Entity.with(A, B, C) {} class WithRef extends Entity.with(Ref) {} +class WithD extends Entity.with(D) {} // composed entities const cWithA = Entity.with(A); @@ -17,4 +18,4 @@ const cWithAB = Entity.with(A, B); const cWithABC = Entity.with(A, B, C); const cWithRef = Entity.with(Ref); -export { WithA, WithB, WithC, WithAB, WithAC, WithABC, WithRef, cWithA, cWithAB, cWithABC, cWithRef }; +export { WithA, WithB, WithC, WithAB, WithAC, WithABC, WithD, WithRef, cWithA, cWithAB, cWithABC, cWithRef }; diff --git a/src/types.ts b/src/types.ts index a33caf2..7e2e9e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -154,9 +154,8 @@ export type NeverByType = Merge< * - given an EntityRef, return the type of the referenced entity. * - given an ordinary component, return the partial type of its data. */ -export type PartialValueByType = InstanceType extends EntityRef - ? R | null - : DeepPartial>; +export type PartialValueByType = + InstanceType extends EntityRef ? R | null : DeepPartial>; export type Ref = T extends EntityRef ? R : never;