diff --git a/src/entity/entity-builder.spec.ts b/src/entity/entity-builder.spec.ts index 85e893a..5ea619a 100644 --- a/src/entity/entity-builder.spec.ts +++ b/src/entity/entity-builder.spec.ts @@ -1,4 +1,4 @@ -import type {IEntity} from "./entity.spec.ts"; +import type {IEntity, TTag} from "./entity.spec.ts"; import type {TObjectProto} from "../_.spec.ts"; export interface IEntityBuilder { @@ -19,6 +19,18 @@ export interface IEntityBuilder { * @param component */ withAll(...component: ReadonlyArray): IEntityBuilder + + /** + * Add tag to target entity + * @param tag + */ + withTag(tag: TTag): IEntityBuilder + + /** + * Add all tags to target entity + * @param tags + */ + withTags(...tags: ReadonlyArray): IEntityBuilder } export type TEntityBuilderProto = { new(): TEntityBuilderProto }; diff --git a/src/entity/entity-builder.test.ts b/src/entity/entity-builder.test.ts new file mode 100644 index 0000000..c1ab215 --- /dev/null +++ b/src/entity/entity-builder.test.ts @@ -0,0 +1,75 @@ +import {assert} from 'chai'; +import {EntityBuilder} from "./entity-builder.ts"; +import {buildWorld} from "../ecs/ecs-world.ts"; +import {createSystem} from "../system/system.ts"; +import {queryComponents} from "../ecs/ecs-query.ts"; +import {Read, WithTag, WithoutTag} from "../query/query.ts"; + +class Component { + health = 100 +} + +const Tag = 'a-tag'; + +describe('Test EntityBuilder', () => { + it('withTag', () => { + const entity = new EntityBuilder().withTag(Tag).build(); + + assert.isTrue(entity.hasTag(Tag)); + assert.equal(entity.getTagCount(), 1); + }); + + it('withTags', () => { + const entity = new EntityBuilder().withTags(Tag, 'another').build(); + + assert.equal(entity.getTagCount(), 2); + }); + + it('deduplicates repeated tags', () => { + const entity = new EntityBuilder().withTag(Tag).withTag(Tag).build(); + + assert.equal(entity.getTagCount(), 1); + }); + + it('applies tags before the entity is handed to the world', () => { + let tagged = false; + new EntityBuilder(undefined, entity => tagged = entity.hasTag(Tag)) + .withTag(Tag) + .build(); + + assert.isTrue(tagged); + }); +}); + +describe('Tags set at build time are visible to queries', () => { + async function count(descriptor: Parameters[0], tagged: boolean) { + const query = queryComponents(descriptor); + const System = createSystem({query}).withName('Probe').withRunFunction(() => {}).build(); + const prepWorld = buildWorld() + .withDefaultScheduling(root => root.addNewStage(stage => stage.addSystem(System))) + .withComponents(Component) + .build(); + const runWorld = await prepWorld.prepareRun(); + const builder = runWorld.buildEntity().with(new Component()); + + if (tagged) { + builder.withTag(Tag); + } + + builder.build(); + + return query.resultLength; + } + + it('WithTag matches an entity tagged by the builder', async () => { + assert.equal(await count({c: Read(Component), _e: WithTag(Tag)}, true), 1); + }); + + it('WithoutTag rejects an entity tagged by the builder', async () => { + assert.equal(await count({c: Read(Component), _e: WithoutTag(Tag)}, true), 0); + }); + + it('WithoutTag matches an entity the builder left untagged', async () => { + assert.equal(await count({c: Read(Component), _e: WithoutTag(Tag)}, false), 1); + }); +}); diff --git a/src/entity/entity-builder.ts b/src/entity/entity-builder.ts index 1e9823e..5879862 100644 --- a/src/entity/entity-builder.ts +++ b/src/entity/entity-builder.ts @@ -1,4 +1,5 @@ import {Entity, type TEntityId} from "./entity.ts"; +import type {TTag} from "./entity.spec.ts"; import type {IEntityBuilder} from "./entity-builder.spec.ts"; import type {TObjectProto} from "../_.spec.ts"; @@ -6,6 +7,7 @@ export * from './entity-builder.spec.ts'; export class EntityBuilder implements IEntityBuilder { protected components = new Map, ReadonlyArray>(); + protected tags = new Set(); constructor( protected uuid?: TEntityId, @@ -15,11 +17,16 @@ export class EntityBuilder implements IEntityBuilder { build(): Entity { const entity = new Entity(this.uuid); let component; + let tag; for (component of this.components) { entity.addComponent(component[0], ...component[1]); } + for (tag of this.tags) { + entity.addTag(tag); + } + this.callback?.(entity); return entity; } @@ -37,4 +44,18 @@ export class EntityBuilder implements IEntityBuilder { return this; } + + withTag(tag: TTag): EntityBuilder { + this.tags.add(tag); + return this; + } + + withTags(...tags: ReadonlyArray): EntityBuilder { + let tag; + for (tag of tags) { + this.withTag(tag); + } + + return this; + } } diff --git a/src/query/components-query.ts b/src/query/components-query.ts index 12ab005..2e992e9 100644 --- a/src/query/components-query.ts +++ b/src/query/components-query.ts @@ -2,14 +2,15 @@ import { IAccessDescriptor, IAccessQuery, IComponentsQuery, + IExistenceDescriptor, TAccessQueryData, TAccessQueryParameter, } from "./query.spec.ts"; -import {EQueryType, ETargetType} from "./query.spec.ts"; +import {EExistence, EQueryType, ETargetType} from "./query.spec.ts"; import {Query} from "./query.ts"; import type {TObjectProto} from "../_.spec.ts"; import type {IEntity, TTag} from "../entity/entity.spec.ts"; -import {accessDescSym, addEntitySym, entitySym} from "./_.ts"; +import {accessDescSym, addEntitySym, entitySym, existenceDescSym} from "./_.ts"; export class ComponentsQuery> extends Query> implements IComponentsQuery { constructor( @@ -28,7 +29,7 @@ export class ComponentsQuery> extends Qu } } - protected getComponentDataFromEntity(entity: Readonly, descriptor: Readonly): Readonly> { + protected getComponentDataFromEntity(entity: Readonly, descriptor: Readonly): TAccessQueryData { const components: Record> = {}; let accessDesc; let componentDesc: Readonly>; @@ -37,6 +38,10 @@ export class ComponentsQuery> extends Qu for ([componentName, componentDesc] of Object.entries(descriptor)) { accessDesc = (componentDesc as IAccessDescriptor)[accessDescSym]; + if (accessDesc === undefined) { + continue; + } + components[componentName] = accessDesc.targetType == ETargetType.component ? (entity.getComponent(accessDesc.target as TObjectProto) ?? entity) : entity; @@ -47,9 +52,24 @@ export class ComponentsQuery> extends Qu matchesEntity(entity: Readonly): boolean { let componentDesc: Readonly>; + let existenceDesc; // @ts-ignore todo: figure out typing. Something is still wrong somewhere for (componentDesc of Object.values(this.queryDescriptor)) { + existenceDesc = (componentDesc as Partial>)[existenceDescSym]; + + if (existenceDesc !== undefined) { + const exists = existenceDesc.targetType == ETargetType.tag + ? entity.hasTag(existenceDesc.target as TTag) + : entity.hasComponent(existenceDesc.target as TObjectProto); + + if (exists != (existenceDesc.type == EExistence.set)) { + return false; + } + + continue; + } + if ( componentDesc[accessDescSym].targetType == ETargetType.tag && !entity.hasTag(componentDesc[accessDescSym].target as TTag) diff --git a/src/query/query.spec.ts b/src/query/query.spec.ts index 9be7864..bd54bf2 100644 --- a/src/query/query.spec.ts +++ b/src/query/query.spec.ts @@ -3,8 +3,8 @@ import type {IEntity, TTag} from "../entity/entity.spec.ts"; import {accessDescSym, addEntitySym, clearEntitiesSym, existenceDescSym, removeEntitySym, setEntitiesSym} from "./_.ts"; export type TAccessQueryParameter = C & IAccessDescriptor>; -export type TOptionalAccessQueryParameter = IAccessDescriptor : undefined> & C extends TObjectProto ? C : undefined; -export interface IAccessQuery { [componentName: string]: TAccessQueryParameter | TOptionalAccessQueryParameter } +export type TOptionalAccessQueryParameter = IAccessDescriptor : undefined> & (C extends TObjectProto ? C : undefined); +export interface IAccessQuery { [componentName: string]: TAccessQueryParameter | TOptionalAccessQueryParameter | TExistenceQueryParameter } export type TExistenceQueryParameter = IExistenceDescriptor; export type TExistenceQuery = Array>; @@ -31,10 +31,13 @@ export enum EQueryType { Entities, } +type TQueryParameterData = Required, keyof IAccessDescriptor>>; + export type TAccessQueryData> = { - [P in keyof DESC]: DESC[P] extends TAccessQueryParameter - ? Required, keyof IAccessDescriptor>> - : (Required, keyof IAccessDescriptor>> | undefined) + [P in keyof DESC as DESC[P] extends IAccessDescriptor ? P : never]: + DESC[P] extends TAccessQueryParameter + ? TQueryParameterData + : (TQueryParameterData | undefined) } export type TComparator = (a: DATA, b: DATA) => number; diff --git a/src/query/query.test.ts b/src/query/query.test.ts index 05fd666..19a609e 100644 --- a/src/query/query.test.ts +++ b/src/query/query.test.ts @@ -1,10 +1,20 @@ -import {Read, ReadEntity, Write} from "./query"; +import {Read, ReadEntity, ReadOptional, With, WithTag, Without, WithoutTag, Write} from "./query"; import {queryComponents} from "../ecs/ecs-query"; +import {buildWorld} from "../ecs/ecs-world"; +import {createSystem} from "../system/system"; +import {Entity} from "../entity/entity"; +import {assert} from "chai"; class Component { health = 100 } +class Marker { + flag = true +} + +const Tag = 'a-tag'; + describe('Test Query', () => { it('pop', () => { const query = queryComponents({ @@ -18,3 +28,88 @@ describe('Test Query', () => { } }); }); + +describe('Existence parameters in component queries', () => { + async function count(descriptor: Parameters[0], tagged: boolean) { + const query = queryComponents(descriptor); + const System = createSystem({query}).withName('Probe').withRunFunction(() => {}).build(); + const prepWorld = buildWorld() + .withDefaultScheduling(root => root.addNewStage(stage => stage.addSystem(System))) + .withComponents(Component, Marker) + .build(); + const runWorld = await prepWorld.prepareRun(); + + const entity = new Entity(); + entity.addComponent(new Component()); + + if (tagged) { + entity.addTag(Tag); + } + + runWorld.addEntity(entity); + + return query.resultLength; + } + + it('With matches an entity which has the component', async () => { + assert.equal(await count({c: Read(Component), _e: With(Component)}, false), 1); + }); + + it('With rejects an entity which lacks the component', async () => { + assert.equal(await count({c: Read(Component), _e: With(Marker)}, false), 0); + }); + + it('Without rejects an entity which has the component', async () => { + assert.equal(await count({c: Read(Component), _e: Without(Component)}, false), 0); + }); + + it('Without matches an entity which lacks the component', async () => { + assert.equal(await count({c: Read(Component), _e: Without(Marker)}, false), 1); + }); + + it('WithTag matches a tagged entity', async () => { + assert.equal(await count({c: Read(Component), _e: WithTag(Tag)}, true), 1); + }); + + it('WithTag rejects an untagged entity', async () => { + assert.equal(await count({c: Read(Component), _e: WithTag(Tag)}, false), 0); + }); + + it('WithoutTag rejects a tagged entity', async () => { + assert.equal(await count({c: Read(Component), _e: WithoutTag(Tag)}, true), 0); + }); + + it('WithoutTag matches an untagged entity', async () => { + assert.equal(await count({c: Read(Component), _e: WithoutTag(Tag)}, false), 1); + }); + + it('still passes optional parameters to the system as data', async () => { + const query = queryComponents({c: Read(Component), o: ReadOptional(Marker)}); + const System = createSystem({query}).withName('Probe').withRunFunction(() => {}).build(); + const prepWorld = buildWorld() + .withDefaultScheduling(root => root.addNewStage(stage => stage.addSystem(System))) + .withComponents(Component, Marker) + .build(); + const runWorld = await prepWorld.prepareRun(); + runWorld.buildEntity().with(new Component()).build(); + + const first = query.getFirst()!; + assert.property(first, 'c'); + assert.property(first, 'o'); + }); + + it('does not pass existence parameters to the system as data', async () => { + const query = queryComponents({c: Read(Component), _e: Without(Marker)}); + const System = createSystem({query}).withName('Probe').withRunFunction(() => {}).build(); + const prepWorld = buildWorld() + .withDefaultScheduling(root => root.addNewStage(stage => stage.addSystem(System))) + .withComponents(Component, Marker) + .build(); + const runWorld = await prepWorld.prepareRun(); + runWorld.buildEntity().with(new Component()).build(); + + const first = query.getFirst()!; + assert.property(first, 'c'); + assert.notProperty(first, '_e'); + }); +});