From a52a2a2ce45817972c5e8cca7fb8ea98abecb613 Mon Sep 17 00:00:00 2001 From: anderoonies Date: Thu, 8 Jul 2021 10:47:08 -0400 Subject: [PATCH 1/5] Typesafe API for defining Components and creating Entities --- docs/Component.md | 19 ++++ docs/World.md | 25 +++++ package-lock.json | 22 ++-- package.json | 3 +- src/index.d.ts | 60 ++++++++-- src/index.js | 11 +- src/world.js | 10 ++ tests/index.ts | 273 ++++++++++++++++++---------------------------- 8 files changed, 233 insertions(+), 190 deletions(-) diff --git a/docs/Component.md b/docs/Component.md index cf2adc2..220278d 100644 --- a/docs/Component.md +++ b/docs/Component.md @@ -295,3 +295,22 @@ Position.properties = { coord: '0x0' }; ``` + +# TypedComponent + +There's an additional API for creating typed Components, which have typed `.properties` defined for the Component class and fields of those types on the instances. This uses the mixin pattern in TypeScript. + +To create a `TypedComponent`: + +```ts +class Position extends ApeECS.TypedComponent({x: 0, y: 0}) {}; +``` + +This creates a `Position` class with `typeof properties === {x: number, y: number}`. +A `TypedComponent` can have `properties` typed as a superset of the initial properties. For example: + +```ts +class Position extends ApeECS.TypedComponent<{x: number, y?: number}>({x: 0}) {}; +``` + +These types are used in the [world.createEntityTypesafe](./World.md#createEntityTypesafe) API. \ No newline at end of file diff --git a/docs/World.md b/docs/World.md index 2973752..f5d1e04 100644 --- a/docs/World.md +++ b/docs/World.md @@ -210,6 +210,31 @@ const playerEntity = world.createEntity({ 💭 **Ape ECS** uses a very fast unique id generator for `Components` and `Entities` if you don't specify a given id upon creation. Look at the code in [src/util.js](../src/util.js). +## createEntityTypesafe + +Create a new Entity, including its type-checked `Components`. This API is slightly reduced from `createEntity`, in that it does not take `components`, only `c`. +The `type` of the `Component` is used to check the types of the initial arguments. + +```ts +class Position extends TypedComponent<{x: number, y?: number}> {}; +class Texture extends TypedComponent<{filePath: string}> {}; + +const playerEntity = world.createEntityTypesafe({ + id: 'Player', // optional + tags: ['Character', 'Visible'], //optional + c: [ // optional + { + type: Position, + x: 15 + }, + { + type: Texture, + filePath: "/assets/img.png", + } + } +}); +``` + ## getObject Retrieves a serializable object that includes all of the Entities and their Components in the World. diff --git a/package-lock.json b/package-lock.json index 509f963..2b3545b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "ape-ecs", "version": "1.3.1", "license": "MIT", "devDependencies": { @@ -19,7 +20,7 @@ "markdown-link-check": "^3.8.3", "mocha": "^8.1.2", "nyc": "^15.1.0", - "prettier": "^2.2.0", + "prettier": "^2.3.2", "ts-node": "^9.0.0", "typescript": "^4.0.2", "webpack": "^4.43.0", @@ -1642,7 +1643,6 @@ "dependencies": { "anymatch": "~3.1.1", "braces": "~3.0.2", - "fsevents": "~2.1.2", "glob-parent": "~5.1.0", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -6463,9 +6463,9 @@ } }, "node_modules/prettier": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.0.tgz", - "integrity": "sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", + "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -8522,8 +8522,7 @@ "dependencies": { "chokidar": "^3.4.1", "graceful-fs": "^4.1.2", - "neo-async": "^2.5.0", - "watchpack-chokidar2": "^2.0.0" + "neo-async": "^2.5.0" }, "optionalDependencies": { "watchpack-chokidar2": "^2.0.0" @@ -8653,6 +8652,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, + "hasInstallScript": true, "optional": true, "os": [ "darwin" @@ -9130,7 +9130,6 @@ "anymatch": "^2.0.0", "async-each": "^1.0.1", "braces": "^2.3.2", - "fsevents": "^1.2.7", "glob-parent": "^3.1.0", "inherits": "^2.0.3", "is-binary-path": "^1.0.0", @@ -9176,6 +9175,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, + "hasInstallScript": true, "optional": true, "os": [ "darwin" @@ -15278,9 +15278,9 @@ "dev": true }, "prettier": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.0.tgz", - "integrity": "sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz", + "integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==", "dev": true }, "pretty-error": { diff --git a/package.json b/package.json index f4d5734..347009e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "url": "https://github.com/fritzy/ape-ecs/issues" }, "homepage": "https://github.com/fritzy/ape-ecs#readme", - "dependencies": {}, "devDependencies": { "@hapi/eslint-plugin-hapi": "^4.3.5", "@types/chai": "^4.2.12", @@ -42,7 +41,7 @@ "markdown-link-check": "^3.8.3", "mocha": "^8.1.2", "nyc": "^15.1.0", - "prettier": "^2.2.0", + "prettier": "^2.3.2", "ts-node": "^9.0.0", "typescript": "^4.0.2", "webpack": "^4.43.0", diff --git a/src/index.d.ts b/src/index.d.ts index 7c5b39b..e225afc 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -72,14 +72,14 @@ export declare class Query { trackAdded: boolean; trackRemoved: boolean; from(...entities: (Entity | string)[]): Query; - fromReverse( + fromReverse( entity: Entity | string, componentName: string | T ): Query; - fromAll(...types: (string | (new () => Component))[]): Query; - fromAny(...types: (string | (new () => Component))[]): Query; - not(...types: (string | (new () => Component))[]): Query; - only(...types: (string | (new () => Component))[]): Query; + fromAll(...types: (string | ComponentClass)[]): Query; + fromAny(...types: (string | ComponentClass)[]): Query; + not(...types: (string | ComponentClass)[]): Query; + only(...types: (string | ComponentClass)[]): Query; persist(trackAdded?: boolean, trackRemoved?: boolean): Query; refresh(): Query; execute(filter?: IQueryExecuteConfig): Set; @@ -90,12 +90,15 @@ export interface IComponentUpdate { [others: string]: any; } -// in order to reference the class rather than the instance -interface ComponentClass { - new (): Component; -} +type DefaultProperties = Record; +type Constructor = new (...args: any[]) => T; +type ComponentClass = ClassType>; +type ClassType = { new (): T }; +export function TypedComponent( + properties: TProperties +): ClassType> & Constructor; -export declare class Component { +export declare class Component { preInit(initial: any): any; init(initial: any): void; get type(): string; @@ -108,6 +111,7 @@ export declare class Component { entity: Entity; id: string; update(values?: IComponentUpdate): void; + properties: TProperties; [name: string]: any; static properties: Object; static serialize: Boolean; @@ -188,6 +192,22 @@ export interface IEntityObject { // export interface IWorldSubscriptions { // [name: string]: System; // } +type TypedComponentConfig = T extends Component + ? { + type: ClassType; + key?: string; + } & TProperties + : never; + +export type TypedComponentConfigVal = T extends Component< + infer TProperties +> + ? { + type: ClassType; + id?: string; + entity?: string; + } & TProperties + : never; export declare class Entity { types: IEntityByType; @@ -208,6 +228,9 @@ export declare class Entity { addComponent( properties: IComponentConfig | IComponentObject ): Component | undefined; + addTypedComponent( + properties: TypedComponentConfig + ): Component | undefined; removeComponent(component: Component | string): boolean; getObject(componentIds?: boolean): IEntityObject; destroy(): void; @@ -228,6 +251,18 @@ export interface IEntityConfig { c?: IComponentConfigValObject; } +export type TypedEntityConfig = { + id?: string; + tags?: string[]; + c?: { + [TComponent in keyof TComponents]: TComponents[TComponent] extends Component< + infer TProperties + > + ? TypedComponentConfigVal + : never; + }; +}; + export interface IPoolStat { active: number; pooled: number; @@ -257,7 +292,7 @@ export declare class World { registerTags(...tags: string[]): void; // Both options allow the passing of a class that extends Component - registerComponent( + registerComponent( klass: T, spinup?: number ): void; @@ -266,6 +301,9 @@ export declare class World { logStats(freq: number, callback?: Function): void; createEntity(definition: IEntityConfig | IEntityObject): Entity; + createEntityTypesafe>( + definition: TypedEntityConfig + ): Entity; getObject(): IEntityObject[]; createEntities(definition: IEntityConfig[] | IEntityObject[]): void; copyTypes(world: World, types: string[]): void; diff --git a/src/index.js b/src/index.js index 279b908..4ceec44 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,18 @@ const { EntityRef, EntitySet, EntityObject } = require('./entityrefs'); +const Component = require('./component'); + +function TypedComponent(props) { + const typedClass = class TypedComponent extends Component {}; + typedClass.properties = { ...props }; + return typedClass; +} + module.exports = { World: require('./world'), System: require('./system'), - Component: require('./component'), Entity: require('./entity'), + Component, + TypedComponent, EntityRef, EntitySet, EntityObject diff --git a/src/world.js b/src/world.js index e02c1a2..ea6f6af 100644 --- a/src/world.js +++ b/src/world.js @@ -255,6 +255,16 @@ module.exports = class World { this.componentPool.set(name, new ComponentPool(this, name, spinup)); } + createEntityTypesafe(definition) { + definition = { + ...definition, + c: definition.c.map((c) => { + return { ...c, type: c.type.name }; + }) + }; + return this.createEntity(definition); + } + createEntity(definition) { return this.entityPool.get(definition); } diff --git a/tests/index.ts b/tests/index.ts index 758edf3..f91ea0b 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,4 +1,3 @@ - import { expect } from 'chai'; import { @@ -10,6 +9,9 @@ import { EntitySet, EntityObject, Query, + TypedComponent, + IComponentConfigVal, + TypedComponentConfigVal } from '../src'; import { SSL_OP_NO_TICKET } from 'constants'; @@ -17,9 +19,9 @@ const ECS = { World, System: System, Component, + TypedComponent }; - class Health extends ECS.Component { static properties = { max: 25, @@ -29,15 +31,19 @@ class Health extends ECS.Component { static typeName = 'Health'; } -describe('express components', () => { +class Position extends ECS.TypedComponent<{ x: number; y?: number }>({ + x: 1, + y: 1 +}) {} +describe('express components', () => { const ecs = new ECS.World(); ecs.registerComponent(Health); + ecs.registerComponent(Position); it('create entity', () => { - - const S1 = class System extends ECS.System {} + const S1 = class System extends ECS.System {}; const s1 = new S1(ecs); ecs.createEntity({ @@ -56,7 +62,6 @@ describe('express components', () => { }); it('create 2nd entity', () => { - ecs.createEntity({ c: { Health: { hp: 10 } @@ -68,8 +73,15 @@ describe('express components', () => { expect(results.size).to.equal(2); }); - it('entity refs', () => { + it('create typesafe entities', () => { + ecs.createEntityTypesafe({ + c: [{ type: Position, x: 1 }] + }); + const results = ecs.createQuery().fromAll(Position).execute(); + expect(results.size).to.equal(1); + }); + it('entity refs', () => { class Storage extends ECS.Component { static properties = { name: 'inventory', @@ -120,7 +132,6 @@ describe('express components', () => { } }); - entity.c.pockets.items.add(food); expect(entity.c.pockets.items.has(food)).to.be.true; @@ -143,11 +154,9 @@ describe('express components', () => { ecs.removeEntity(entity.id); expect(ecs.getEntity(entity.id)).to.be.undefined; - }); it('init and destroy component', () => { - let hit = false; const ecs = new ECS.World(); @@ -166,43 +175,35 @@ describe('express components', () => { init() { this.y++; } - } ecs.registerComponent(Test); const entity = ecs.createEntity({ c: { - Test: { - } + Test: {} } }); - expect(entity.c.Test.y).to.equal(1); expect(hit).to.equal(false); entity.removeComponent(entity.c.Test); expect(hit).to.equal(true); - }); it('system subscriptions', () => { - let changes = []; let changes2 = []; let effectExt = null; /* $lab:coverage:off$ */ class System extends ECS.System { - init(a, b) { - this.subscribe('EquipmentSlot'); expect(a).to.equal(1); expect(b).to.equal('b2'); } update(tick) { - changes = this.changes; for (const change of this.changes) { const parent = this.world.getEntity(change.entity); @@ -241,16 +242,13 @@ describe('express components', () => { } class System2 extends ECS.System { - init(a, b, c) { - expect(a).to.equal(2); expect(b).to.equal(4); expect(c).to.equal('a'); } update(tick) { - changes2 = this.changes; } } @@ -267,13 +265,11 @@ describe('express components', () => { class Wearable extends ECS.Component { static properties = { name: 'ring', - effects: [ - { type: 'Burning' } - ] + effects: [{ type: 'Burning' }] }; } - class Burning extends ECS.Component {}; + class Burning extends ECS.Component {} ecs.registerComponent(EquipmentEffect); ecs.registerComponent(Wearable); @@ -302,11 +298,7 @@ describe('express components', () => { const pants = ecs.createEntity({ c: { - Wearable: { name: 'Nice Pants', - effects: [ - { type: 'Burning' } - ] - } + Wearable: { name: 'Nice Pants', effects: [{ type: 'Burning' }] } } }); @@ -319,7 +311,9 @@ describe('express components', () => { expect(entity.getComponents('EquipmentEffect')).to.not.be.empty; expect(entity.getComponents(EquipmentEffect)).to.not.be.empty; - const eEffects = new Set([...entity.getComponents('EquipmentEffect')][0].effects); + const eEffects = new Set( + [...entity.getComponents('EquipmentEffect')][0].effects + ); expect(eEffects.has(effectExt.id)).to.be.true; expect(entity.getComponents('Burning')).to.not.be.empty; @@ -337,13 +331,9 @@ describe('express components', () => { expect(changes[0].target).to.equal(pants.id); expect(entity.getComponents('EquipmentEffect')).to.be.empty; expect(entity.getComponents('Burning')).to.be.empty; - }); - - it('system subscriptions with updated components', () => { - let changes = []; class Food2 extends ECS.Component { @@ -355,21 +345,17 @@ describe('express components', () => { } class System extends ECS.System { - init() { - this.subscribe(Food2); } update(tick) { - for (const cng of this.changes) { changes.push(cng); } } } - ecs.registerComponent(Food2); const system = new System(ecs); ecs.registerSystem('equipment', system); @@ -377,7 +363,7 @@ describe('express components', () => { const e0 = ecs.createEntity({ c: { - food: { type: 'Food2', rot: 4 }, + food: { type: 'Food2', rot: 4 } } }); @@ -387,7 +373,7 @@ describe('express components', () => { const e1 = ecs.createEntity({ c: { - food: { type: 'Food2', rot: 5 }, + food: { type: 'Food2', rot: 5 } } }); @@ -396,14 +382,14 @@ describe('express components', () => { expect(e1.c.food.rot).to.equal(5); expect(e1.c.food.restore).to.equal(2); - e1.c.food.update({rot:6}); + e1.c.food.update({ rot: 6 }); expect(e1.c.food.rot).to.equal(6); expect(e1.c.food.restore).to.equal(2); ecs.runSystems('equipment'); - e1.c.food.update({rot:0,restore:0}); + e1.c.food.update({ rot: 0, restore: 0 }); expect(e1.c.food.rot).to.equal(0); expect(e1.c.food.restore).to.equal(0); @@ -423,11 +409,9 @@ describe('express components', () => { }); describe('system queries', () => { - const ecs = new ECS.World(); it('add and remove forbidden component', () => { - class Tile extends ECS.Component { static properties = { x: 0, @@ -442,21 +426,19 @@ describe('system queries', () => { ecs.registerComponent(Hidden); class TileSystem extends ECS.System { - lastResults: Set; query: Query; init() { - this.lastResults = new Set(); this.query = this.world.createQuery({ all: ['Tile'], not: ['Hidden'], - persist: true }); + persist: true + }); } update(tick) { - this.lastResults = this.query.execute(); } } @@ -489,7 +471,7 @@ describe('system queries', () => { } }); - ecs.tick() + ecs.tick(); ecs.runSystems('map'); @@ -512,14 +494,11 @@ describe('system queries', () => { expect(tileSystem.lastResults.size).to.equal(1); expect(tileSystem.lastResults.has(tile2)).to.be.true; - - }); it('multiple has and hasnt', () => { - - class Billboard extends ECS.Component {}; - class Sprite extends ECS.Component {}; + class Billboard extends ECS.Component {} + class Sprite extends ECS.Component {} ecs.registerComponent(Billboard); ecs.registerComponent(Sprite); @@ -535,7 +514,7 @@ describe('system queries', () => { const tile2 = ecs.createEntity({ c: { Tile: {}, - Billboard: {}, + Billboard: {} } }); @@ -549,18 +528,19 @@ describe('system queries', () => { const tile4 = ecs.createEntity({ c: { - Tile: {}, + Tile: {} } }); const tile5 = ecs.createEntity({ c: { - Billboard: {}, + Billboard: {} } }); - const result = ecs.createQuery() - .fromAll('Tile', 'Billboard') + const result = ecs + .createQuery() + .fromAll('Tile', Billboard) .not(Sprite, 'Hidden') .execute(); @@ -571,14 +551,12 @@ describe('system queries', () => { expect(resultSet.has(tile3)).to.be.false; expect(resultSet.has(tile4)).to.be.false; expect(resultSet.has(tile5)).to.be.false; - }); it('tags', () => { - const ecs = new ECS.World(); - class Tile extends ECS.Component {}; - class Sprite extends ECS.Component {}; + class Tile extends ECS.Component {} + class Sprite extends ECS.Component {} ecs.registerComponent(Tile); ecs.registerComponent(Sprite); @@ -607,7 +585,7 @@ describe('system queries', () => { const tile4 = ecs.createEntity({ c: { - Tile: {}, + Tile: {} } }); @@ -615,10 +593,12 @@ describe('system queries', () => { tags: ['Billboard'] }); - const q1 = ecs.createQuery({ - all: ['Tile', 'Billboard'], - not: ['Sprite', 'Hidden'], - }).persist(); + const q1 = ecs + .createQuery({ + all: ['Tile', 'Billboard'], + not: ['Sprite', 'Hidden'] + }) + .persist(); const result = q1.execute(); const resultSet = new Set([...result]); @@ -663,11 +643,9 @@ describe('system queries', () => { expect(result2.has(tile3)).to.be.false; expect(result2.has(tile4)).to.be.false; expect(result2.has(tile5)).to.be.false; - }); it('filter by updatedValues', () => { - const ecs = new ECS.World(); class Comp1 extends ECS.Component { static properties = { @@ -701,7 +679,6 @@ describe('system queries', () => { }); it('filter by updatedComponents', () => { - const ecs = new ECS.World(); class Comp1 extends ECS.Component { static properties = { @@ -745,11 +722,9 @@ describe('system queries', () => { const results2 = testQ.execute({ updatedComponents: ticks }); expect(results2.has(entity1)).to.be.false; expect(results2.has(entity2)).to.be.true; - }); it('destroyed entity should be cleared', () => { - const ecs = new ECS.World(); class Comp1 extends ECS.Component {} ecs.registerComponent(Comp1); @@ -770,18 +745,15 @@ describe('system queries', () => { const results2 = query.execute(); expect(results2.has(entity1)).to.be.false; - }); }); - describe('entity & component refs', () => { - const ecs = new ECS.World(); class BeltSlots extends ECS.Component { static properties = { - slots: EntityObject, + slots: EntityObject }; } class Potion extends ECS.Component {} @@ -790,7 +762,6 @@ describe('entity & component refs', () => { ecs.registerComponent(Potion); it('Entity Object', () => { - const belt = ecs.createEntity({ c: { BeltSlots: {} @@ -806,7 +777,7 @@ describe('entity & component refs', () => { Potion: {} } }); - beltslots.slots[slot] = potion; + beltslots.slots[slot] = potion; potions.push(potion); } @@ -838,14 +809,15 @@ describe('entity & component refs', () => { // Calling delete on a EntityObject component that does // not exist should return false // when in strict mode, this will throw an exception - expect(()=>{delete beltslots.slots.d}).to.throw(TypeError); + expect(() => { + delete beltslots.slots.d; + }).to.throw(TypeError); }); it('Entity Set', () => { - class BeltSlots2 extends ECS.Component { static properties = { - slots: EntitySet, + slots: EntitySet }; } ecs.registerComponent(BeltSlots2); @@ -878,7 +850,7 @@ describe('entity & component refs', () => { const withValues = ecs.createEntity({ c: { - BeltSlots: { slots: { a: potions[0].id, b: potions[2], d: null }} + BeltSlots: { slots: { a: potions[0].id, b: potions[2], d: null } } } }); @@ -892,8 +864,6 @@ describe('entity & component refs', () => { expect(withValues.c.BeltSlots.slots.c).to.equal(undefined); withValues.c.BeltSlots.slots.c = potions[1]; expect(withValues.c.BeltSlots.slots.c).to.equal(potions[1]); - - }); class Crying extends ECS.Component {} @@ -902,7 +872,6 @@ describe('entity & component refs', () => { ecs.registerComponent(Angry); it('Assign entity ref by id', () => { - class Ref extends ECS.Component { static properties = { other: EntityRef @@ -926,7 +895,6 @@ describe('entity & component refs', () => { }); it('Reassign same entity ref', () => { - const entity = ecs.createEntity({ c: { Crying: {} @@ -943,13 +911,10 @@ describe('entity & component refs', () => { expect(entity2.c.Ref.other).to.equal(entity); }); - }); describe('entity restore', () => { - it('restore mapped object', () => { - const ecs = new ECS.World(); ecs.registerTags('Potion'); @@ -961,7 +926,6 @@ describe('entity restore', () => { } ecs.registerComponent(EquipmentSlot); - const potion1 = ecs.createEntity({ tags: ['Potion'] }); @@ -971,8 +935,8 @@ describe('entity restore', () => { const entity = ecs.createEntity({ c: { - 'main': { slot: potion1, type: 'EquipmentSlot' }, - 'secondary': { slot: potion2, type: 'EquipmentSlot' } + main: { slot: potion1, type: 'EquipmentSlot' }, + secondary: { slot: potion2, type: 'EquipmentSlot' } } }); @@ -982,7 +946,6 @@ describe('entity restore', () => { }); it('restore unmapped object', () => { - const ecs = new ECS.World(); ecs.registerTags('Potion'); @@ -994,7 +957,6 @@ describe('entity restore', () => { } ecs.registerComponent(EquipmentSlot); - const potion1 = ecs.createEntity({ tags: ['Potion'] }); @@ -1029,7 +991,6 @@ describe('entity restore', () => { }); it('Unregistered component throws', () => { - const ecs = new ECS.World(); ecs.registerComponent(class Potion extends ECS.Component {}); @@ -1044,9 +1005,8 @@ describe('entity restore', () => { }); it('Unassigned field is not set', () => { - const ecs = new ECS.World(); - class Potion extends ECS.Component {}; + class Potion extends ECS.Component {} ecs.registerComponent(Potion); const entity = ecs.createEntity({ c: { @@ -1057,17 +1017,18 @@ describe('entity restore', () => { }); it('removeComponentByName many', () => { - const ecs = new ECS.World(); ecs.registerComponent(class NPC extends ECS.Component {}); ecs.registerComponent(class Other extends ECS.Component {}); - ecs.registerComponent(class Armor extends ECS.Component { - static properties = { 'amount': 5 }; - }); + ecs.registerComponent( + class Armor extends ECS.Component { + static properties = { amount: 5 }; + } + ); const entity = ecs.createEntity({ c: { - NPC: {}, + NPC: {} } }); entity.addComponent({ type: 'Armor', amount: 10 }); @@ -1098,11 +1059,9 @@ describe('entity restore', () => { expect(removed).to.be.true; expect(removed2).to.be.false; - }); it('EntitySet', () => { - const ecs = new ECS.World(); class SetInventory extends ECS.Component { static properties = { @@ -1112,7 +1071,7 @@ describe('entity restore', () => { class Bottle extends ECS.Component {} class ThrowAway extends ECS.Component { static properties = { - a: 1, + a: 1 }; static serialize = false; } @@ -1184,7 +1143,7 @@ describe('entity restore', () => { expect(setInv.slots.has(bottle1)).to.be.false; expect(setInv.slots.has(bottle2)).to.be.true; - setInv.slots.clear() + setInv.slots.clear(); expect(setInv.slots.has(bottle2)).to.be.false; const bottle4 = ecs.createEntity({ @@ -1213,16 +1172,11 @@ describe('entity restore', () => { withValues.c.SetInventory.slots._reset(); expect(withValues.c.SetInventory.slots.has(bottle4)).to.be.true; expect(withValues.c.SetInventory.slots.has(bottle5)).to.be.true; - - }); - }); describe('exporting and restoring', () => { - it('get object and stringify component', () => { - const ecs = new ECS.World(); class AI extends ECS.Component { static properties = { @@ -1234,7 +1188,7 @@ describe('exporting and restoring', () => { const entity = ecs.createEntity({ c: { moon: { type: 'AI', order: 'moon' }, - jupiter: { type: 'AI', order: 'jupiter' }, + jupiter: { type: 'AI', order: 'jupiter' } } }); @@ -1246,7 +1200,6 @@ describe('exporting and restoring', () => { }); it('getObject on entity', () => { - const ecs = new ECS.World(); class EquipmentSlot extends ECS.Component { static properties = { @@ -1300,7 +1253,6 @@ describe('exporting and restoring', () => { }); it('property skipping', () => { - const ecs = new ECS.World(); class Effect extends ECS.Component { static properties = { @@ -1351,12 +1303,10 @@ describe('exporting and restoring', () => { expect(entity2.c.OtherLiquid).to.not.exist; expect(entity2.c.Liquid).to.not.exist; }); - }); describe('advanced queries', () => { it('from and reverse queries', () => { - const ecs = new ECS.World(); ecs.registerTags('A', 'B', 'C', 'D'); @@ -1378,14 +1328,14 @@ describe('advanced queries', () => { const q = ecs.createQuery().from(entity1, entity2.id, entity3); const r = q.execute(); - + expect(r.has(entity1)).to.be.true; expect(r.has(entity2)).to.be.true; expect(r.has(entity3)).to.be.true; const q1b = ecs.createQuery({ from: [entity1, entity2.id, entity3] }); const r1b = q.execute(); - + expect(r1b.has(entity1)).to.be.true; expect(r1b.has(entity2)).to.be.true; expect(r1b.has(entity3)).to.be.true; @@ -1429,7 +1379,10 @@ describe('advanced queries', () => { } }); - const q2 = ecs.createQuery({ reverse: { entity: e4, type: 'InInventory'}, persist: true } ); + const q2 = ecs.createQuery({ + reverse: { entity: e4, type: 'InInventory' }, + persist: true + }); const r2 = q2.execute(); expect(r2.size).to.equal(1); @@ -1492,7 +1445,7 @@ describe('advanced queries', () => { tags: ['D', 'B', 'A'] }); - const q5 = ecs.createQuery().fromAll('A').only('D', 'C', Item) + const q5 = ecs.createQuery().fromAll('A').only('D', 'C', Item); const rq5 = q5.execute(); expect(rq5.has(entity5)).to.be.true; @@ -1504,28 +1457,25 @@ describe('advanced queries', () => { const rq5b = q5b.execute(); expect(rq5b.has(entity5)).to.be.true; - }); it('track added and removed', () => { - const ecs = new ECS.World(); class S1 extends ECS.System { - q1: Query; init() { - this.q1 = this.createQuery({ - trackAdded: true, - }).fromAll('A', 'C').persist(); + trackAdded: true + }) + .fromAll('A', 'C') + .persist(); } - - update(tick) { + update(tick) { const r1 = this.q1.execute(); - switch(tick) { + switch (tick) { case 0: expect(r1.has(e5)).to.be.true; expect(r1.has(e6)).to.be.false; @@ -1553,7 +1503,6 @@ describe('advanced queries', () => { expect(this.q1.removed.size).to.be.equal(0); break; } - } } class S2 extends ECS.System {} @@ -1594,9 +1543,12 @@ describe('advanced queries', () => { ecs.runSystems('group1'); ecs.tick(); - const q2 = s2.createQuery({ - trackRemoved: true, - }).fromAll('A', 'C').persist(); + const q2 = s2 + .createQuery({ + trackRemoved: true + }) + .fromAll('A', 'C') + .persist(); const r2 = q2.execute(); @@ -1630,14 +1582,11 @@ describe('advanced queries', () => { ecs.runSystems('group2'); expect(q2.added.size).to.be.equal(0); expect(q2.removed.size).to.be.equal(0); - }); }); describe('serialize and deserialize', () => { - it('maintain refs across worlds', () => { - const worldA = new ECS.World(); class Inventory extends ECS.Component { @@ -1681,7 +1630,7 @@ describe('serialize and deserialize', () => { const q1 = worldB.createQuery().fromAll('NPC'); const r1 = [...q1.execute()]; - const npc2 = r1[0] + const npc2 = r1[0]; const bottle2 = [...npc2.c.Inventory.main][0]; expect(npc.id).to.equal(npc2.id); @@ -1703,7 +1652,6 @@ describe('serialize and deserialize', () => { }); it('filters serlizable fields', () => { - const world = new ECS.World(); class T1 extends ECS.Component { static properties = { @@ -1759,7 +1707,6 @@ describe('serialize and deserialize', () => { describe('pool stats', () => { it('logs output', () => { - const ecs = new ECS.World({ entityPool: 10 }); @@ -1769,13 +1716,13 @@ describe('pool stats', () => { logs.push(output); } - class Test extends Component {}; + class Test extends Component {} ecs.registerComponent(Test, 50); ecs.logStats(2, logStats); for (let i = 0; i < 1000; i++) { ecs.createEntity({ - components: [{type: 'Test'}] + components: [{ type: 'Test' }] }); } @@ -1798,7 +1745,7 @@ describe('pool stats', () => { for (let i = 0; i < 14; i++) { ecs.tick(); } - + expect(logs.length).to.equal(7); const stats2 = ecs.getStats(); const stest2 = stats2.components.Test; @@ -1815,7 +1762,6 @@ describe('pool stats', () => { describe('ApeDestroy', () => { it('Test ApeDestroy Queries', () => { - const ecs = new World({ useApeDestroy: true }); @@ -1827,9 +1773,11 @@ describe('ApeDestroy', () => { const e1 = ecs.createEntity({ tags: ['A'], - components: [{ - type: 'Test' - }] + components: [ + { + type: 'Test' + } + ] }); const q1 = ecs.createQuery().fromAll('Test', 'A'); @@ -1837,7 +1785,7 @@ describe('ApeDestroy', () => { expect(r1).contains(e1); - const q1b = ecs.createQuery({ all: ['Test', 'A']}); + const q1b = ecs.createQuery({ all: ['Test', 'A'] }); const r1b = q1b.execute(); expect(r1b).contains(e1); @@ -1849,7 +1797,9 @@ describe('ApeDestroy', () => { expect(r2).not.contains(e1); - const q3 = ecs.createQuery({ includeApeDestroy: true, not: ['B'] }).fromAll('Test', 'A'); + const q3 = ecs + .createQuery({ includeApeDestroy: true, not: ['B'] }) + .fromAll('Test', 'A'); const r3 = q3.execute(); expect(r3).contains(e1); @@ -1868,9 +1818,8 @@ describe('Component Portability', () => { const world2 = new World(); class Testa extends Component { - static properties = { - greeting: "Hi", + greeting: 'Hi', a: 1 }; @@ -1878,7 +1827,6 @@ describe('Component Portability', () => { greeting: string; a: number; - } world1.registerComponent(Testa); @@ -1899,8 +1847,8 @@ describe('Component Portability', () => { const q1 = world1.createQuery().fromAll('Test'); const q2 = world2.createQuery().fromAll('Test'); - t1.c.Test.greeting = "Hello"; - t2.c.Test.greeting = "Howdy"; + t1.c.Test.greeting = 'Hello'; + t2.c.Test.greeting = 'Howdy'; const r1 = q1.execute(); const r2 = q2.execute(); @@ -1909,19 +1857,15 @@ describe('Component Portability', () => { expect(r1).contains(t1); expect(r2).contains(t2); expect(t1.c.Test.greeting).is.equal('Hello'); - }); }); describe('Regressions', () => { - it('#66 Calling destroy twice has very odd effects', () => { - const world = new World({ entityPool: 1 }); class TestA extends Component { - static properties = { - greeting: "Hi", + greeting: 'Hi', a: 1 }; @@ -1931,9 +1875,8 @@ describe('Regressions', () => { } class TestB extends Component { - static properties = { - greeting: "Hi", + greeting: 'Hi', a: 1 }; @@ -1948,7 +1891,7 @@ describe('Regressions', () => { const e = world.createEntity({ c: { TestA: { - greeting: "What", + greeting: 'What', a: 2 } } @@ -1959,7 +1902,7 @@ describe('Regressions', () => { const e2 = world.createEntity({ c: { TestB: { - greeting: "No", + greeting: 'No', a: 3 } } From 293f45229d3993fad457d29fb112aaa002ed4052 Mon Sep 17 00:00:00 2001 From: anderoonies Date: Sun, 11 Jul 2021 16:54:38 -0400 Subject: [PATCH 2/5] Overload addComponent to support both TypedComponent interface and type: string interface. Tests --- docs/World.md | 8 +++++--- src/entity.js | 6 ++++-- src/index.d.ts | 48 ++++++++++++++++++------------------------------ tests/index.ts | 7 +++++++ 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/docs/World.md b/docs/World.md index f5d1e04..fbd986f 100644 --- a/docs/World.md +++ b/docs/World.md @@ -219,19 +219,21 @@ The `type` of the `Component` is used to check the types of the initial argument class Position extends TypedComponent<{x: number, y?: number}> {}; class Texture extends TypedComponent<{filePath: string}> {}; -const playerEntity = world.createEntityTypesafe({ +const playerEntity = world.createEntityTypesafe<[{type: Position}, {type: Texture}]>({ id: 'Player', // optional tags: ['Character', 'Visible'], //optional c: [ // optional { type: Position, - x: 15 + x: 15, + z: 1 + // ^ errors }, { type: Texture, filePath: "/assets/img.png", } - } + ] }); ``` diff --git a/src/entity.js b/src/entity.js index 181014d..2290948 100644 --- a/src/entity.js +++ b/src/entity.js @@ -99,7 +99,10 @@ class Entity { } addComponent(properties) { - const type = properties.type; + let type = properties.type; + if (typeof type !== 'string') { + type = type.name; + } const pool = this.world.componentPool.get(type); if (pool === undefined) { throw new Error(`Component "${type}" has not been registered.`); @@ -163,7 +166,6 @@ class Entity { } destroy() { - if (this.destroyed) return; if (this.world.refs[this.id]) { for (const ref of this.world.refs[this.id]) { diff --git a/src/index.d.ts b/src/index.d.ts index e225afc..a136196 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -90,13 +90,12 @@ export interface IComponentUpdate { [others: string]: any; } -type DefaultProperties = Record; -type Constructor = new (...args: any[]) => T; -type ComponentClass = ClassType>; -type ClassType = { new (): T }; +type DefaultProperties = {}; +type Constructor = { new (...args: any[]): T }; +type ComponentClass = Constructor>; export function TypedComponent( - properties: TProperties -): ClassType> & Constructor; + properties?: TProperties +): Constructor> & Constructor; export declare class Component { preInit(initial: any): any; @@ -192,21 +191,15 @@ export interface IEntityObject { // export interface IWorldSubscriptions { // [name: string]: System; // } -type TypedComponentConfig = T extends Component +type TypedComponentConfig = T extends Component ? { - type: ClassType; + type: Constructor; key?: string; } & TProperties : never; -export type TypedComponentConfigVal = T extends Component< - infer TProperties -> - ? { - type: ClassType; - id?: string; - entity?: string; - } & TProperties +export type TypedComponentConfigVal = T extends Component + ? { type: Constructor; id?: string; entity?: string } & TProperties : never; export declare class Entity { @@ -225,11 +218,10 @@ export declare class Entity { getComponents(type: { new (): T }): Set; addTag(tag: string): void; removeTag(tag: string): void; - addComponent( - properties: IComponentConfig | IComponentObject - ): Component | undefined; - addTypedComponent( - properties: TypedComponentConfig + addComponent( + properties: T extends Component + ? TypedComponentConfig + : IComponentConfig | IComponentObject ): Component | undefined; removeComponent(component: Component | string): boolean; getObject(componentIds?: boolean): IEntityObject; @@ -251,15 +243,11 @@ export interface IEntityConfig { c?: IComponentConfigValObject; } -export type TypedEntityConfig = { +export type TypedEntityConfig = { id?: string; tags?: string[]; - c?: { - [TComponent in keyof TComponents]: TComponents[TComponent] extends Component< - infer TProperties - > - ? TypedComponentConfigVal - : never; + c: { + [K in keyof TComponents]: TypedComponentConfigVal; }; }; @@ -301,8 +289,8 @@ export declare class World { logStats(freq: number, callback?: Function): void; createEntity(definition: IEntityConfig | IEntityObject): Entity; - createEntityTypesafe>( - definition: TypedEntityConfig + createEntityTypesafe( + definition: TypedEntityConfig<[...T]> ): Entity; getObject(): IEntityObject[]; createEntities(definition: IEntityConfig[] | IEntityObject[]): void; diff --git a/tests/index.ts b/tests/index.ts index f91ea0b..7e7a459 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -81,6 +81,13 @@ describe('express components', () => { expect(results.size).to.equal(1); }); + it('addComponent with type!=string', () => { + const e = ecs.createEntity({}); + e.addComponent({ type: Position, x: 1 }); + const results = ecs.createQuery().fromAll(Position).execute(); + expect(results.size).to.equal(2); + }); + it('entity refs', () => { class Storage extends ECS.Component { static properties = { From d69d0a246101f8cd088ea04d48073b4a251cd986 Mon Sep 17 00:00:00 2001 From: anderoonies Date: Tue, 10 Aug 2021 08:00:30 -0400 Subject: [PATCH 3/5] Move createEntityTypesafe from using c: to components:, update docs --- docs/Component.md | 4 ++-- docs/Entity.md | 10 ++++++++++ docs/World.md | 10 +++++++--- src/index.d.ts | 2 +- tests/index.ts | 2 +- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/Component.md b/docs/Component.md index 220278d..79bf9cf 100644 --- a/docs/Component.md +++ b/docs/Component.md @@ -306,11 +306,11 @@ To create a `TypedComponent`: class Position extends ApeECS.TypedComponent({x: 0, y: 0}) {}; ``` -This creates a `Position` class with `typeof properties === {x: number, y: number}`. +This creates a `Position` class with properties typed `{x: number, y: number}`. A `TypedComponent` can have `properties` typed as a superset of the initial properties. For example: ```ts class Position extends ApeECS.TypedComponent<{x: number, y?: number}>({x: 0}) {}; ``` -These types are used in the [world.createEntityTypesafe](./World.md#createEntityTypesafe) API. \ No newline at end of file +These types are used in the [world.createEntityTypesafe](./World.md#createEntityTypesafe) API and [entity.addComponent](./Entity.md#addComponent). \ No newline at end of file diff --git a/docs/Entity.md b/docs/Entity.md index 038a5c3..7530f0d 100644 --- a/docs/Entity.md +++ b/docs/Entity.md @@ -124,6 +124,16 @@ entity.addComponent({ Setting a key makes the `Component` instance accessible as a property of the `Entity`. +👆 Using the api `addComponent({type: Point})` (using the `Point` class rather than a string) will enforce type checking for that component in TypeScript. +```ts +entity.addComponent({ + type: Point, + x: 123, + y: 'three', + // ^ error +}) +``` + 💭 It can sometimes be useful to set a custom id for an `Entity`, but there may not be a valid usecase for a new `Component`. You should generally only specify the `id` in `addComponent` if you're restoring a previous `Component` from `getObject`. 👀 See [world.createEntity](./World.md#createEntity) for another perspective on `Component` instance definitions. diff --git a/docs/World.md b/docs/World.md index fbd986f..a130dc1 100644 --- a/docs/World.md +++ b/docs/World.md @@ -212,17 +212,21 @@ const playerEntity = world.createEntity({ ## createEntityTypesafe -Create a new Entity, including its type-checked `Components`. This API is slightly reduced from `createEntity`, in that it does not take `components`, only `c`. -The `type` of the `Component` is used to check the types of the initial arguments. +Create a new Entity, including its type-checked `Components`. This API is slightly reduced from `createEntity`, in that it does not `c`. +The `type` of the `Component` is used to check the types of the initial arguments, and `type` must be a Component class. ```ts class Position extends TypedComponent<{x: number, y?: number}> {}; class Texture extends TypedComponent<{filePath: string}> {}; +class Flag extends TypedComponent() {}; const playerEntity = world.createEntityTypesafe<[{type: Position}, {type: Texture}]>({ id: 'Player', // optional tags: ['Character', 'Visible'], //optional - c: [ // optional + components: [ // optional + { + type: Flag, + }, { type: Position, x: 15, diff --git a/src/index.d.ts b/src/index.d.ts index a136196..c595569 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -246,7 +246,7 @@ export interface IEntityConfig { export type TypedEntityConfig = { id?: string; tags?: string[]; - c: { + components: { [K in keyof TComponents]: TypedComponentConfigVal; }; }; diff --git a/tests/index.ts b/tests/index.ts index 7e7a459..75d2984 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -75,7 +75,7 @@ describe('express components', () => { it('create typesafe entities', () => { ecs.createEntityTypesafe({ - c: [{ type: Position, x: 1 }] + components: [{ type: Position, x: 1 }] }); const results = ecs.createQuery().fromAll(Position).execute(); expect(results.size).to.equal(1); From 1a492cab4c7755ecfc636bb179652dbd8361d2bb Mon Sep 17 00:00:00 2001 From: anderoonies Date: Tue, 10 Aug 2021 08:40:53 -0400 Subject: [PATCH 4/5] Tests for createEntityTypsafe with components: --- src/world.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/world.js b/src/world.js index ea6f6af..22cbc4d 100644 --- a/src/world.js +++ b/src/world.js @@ -258,7 +258,7 @@ module.exports = class World { createEntityTypesafe(definition) { definition = { ...definition, - c: definition.c.map((c) => { + components: definition.components.map((c) => { return { ...c, type: c.type.name }; }) }; From 4a92ade646564f24c01947d2dde9a0b4e9a70272 Mon Sep 17 00:00:00 2001 From: anderoonies Date: Mon, 23 Aug 2021 09:45:59 -0400 Subject: [PATCH 5/5] Update World docs to remove need for explicitly typing createEntityTypesafe --- docs/World.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/World.md b/docs/World.md index a130dc1..48ca02f 100644 --- a/docs/World.md +++ b/docs/World.md @@ -220,7 +220,7 @@ class Position extends TypedComponent<{x: number, y?: number}> {}; class Texture extends TypedComponent<{filePath: string}> {}; class Flag extends TypedComponent() {}; -const playerEntity = world.createEntityTypesafe<[{type: Position}, {type: Texture}]>({ +const playerEntity = world.createEntityTypesafe({ id: 'Player', // optional tags: ['Character', 'Visible'], //optional components: [ // optional