Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support any, all and not component/tag filters on Query #3380

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@
"allowTernary": true
}
],
"no-restricted-syntax": [
"error",
{
"message": "Do not commit tests with 'fit'",
"selector": "CallExpression[callee.name='fit'] > Identifier"
}
],
"jsdoc/require-param": 0,
"jsdoc/require-param-description": 0,
"jsdoc/require-param-type": 0,
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Added ability to use `ex.Vector` to specify offset and margin in `SpriteSheet.fromImageSource({..})`
- New PostProcessor.onDraw() hook to handle uploading textures
- Adds contact solve bias to RealisticSolver, this allows customization on which direction contacts are solved first. By default there is no bias set to 'none'.
- Queries can now take additional options to filter in/out by components or tags.

```ts
const query = new Query({
// all fields are optional
components: {
all: [ComponentA, ComponentB] as const, // important for type safety!
any: [ComponentC, ComponentD] as const, // important for type safety!
not: [ComponentE]
},
tags: {
all: ['tagA', 'tagB'],
any: ['tagC', 'tagD'],
not: ['tagE']
}
})

// previous constructor type still works and is shorthand for components.all
new Query([ComponentA, ComponentB] as const)
```

- Queries can now match all entities by specifying no filters

```ts
const query = new Query({})
```


### Fixed

Expand Down
2 changes: 1 addition & 1 deletion karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ module.exports = (config) => {
],
client: {
// Excalibur logs / console logs suppressed when captureConsole = false;
captureConsole: false,
captureConsole: process.env.CAPTURE_CONSOLE === 'true',
jasmine: {
random: true,
timeoutInterval: 70000 // needs to be bigger than no-activity
Expand Down
171 changes: 153 additions & 18 deletions src/engine/EntityComponentSystem/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ import { Component, ComponentCtor } from '../EntityComponentSystem/Component';

export type ComponentInstance<T> = T extends ComponentCtor<infer R> ? R : never;

/**
* Turns `Entity<A | B>` into `Entity<A> | Entity<B>`
*/
export type DistributeEntity<T> = T extends infer U extends Component ? Entity<U> : never;

export interface QueryParams<
TKnownComponentCtors extends ComponentCtor<Component> = never,
TAnyComponentCtors extends ComponentCtor<Component> = never
> {
components?: {
all?: TKnownComponentCtors[];
any?: TAnyComponentCtors[];
not?: ComponentCtor<Component>[];
};
tags?: {
all?: string[];
any?: string[];
not?: string[];
};
}

export type QueryEntity<
TAllComponentCtors extends ComponentCtor<Component> = never,
TAnyComponentCtors extends ComponentCtor<Component> = never
> = [TAnyComponentCtors] extends [never] // (trick to exclude `never` explicitly)
? Entity<ComponentInstance<TAllComponentCtors>>
: Entity<ComponentInstance<TAllComponentCtors>> | DistributeEntity<ComponentInstance<TAnyComponentCtors>>;

/**
* Represents query for entities that match a list of types that is cached and observable
*
Expand All @@ -12,47 +40,154 @@ export type ComponentInstance<T> = T extends ComponentCtor<infer R> ? R : never;
* const queryAB = new ex.Query<ComponentTypeA | ComponentTypeB>(['A', 'B']);
* ```
*/
export class Query<TKnownComponentCtors extends ComponentCtor<Component> = never> {
export class Query<
TAllComponentCtors extends ComponentCtor<Component> = never,
TAnyComponentCtors extends ComponentCtor<Component> = never
> {
public readonly id: string;
public components = new Set<TKnownComponentCtors>();
public entities: Entity<ComponentInstance<TKnownComponentCtors>>[] = [];

public entities: QueryEntity<TAllComponentCtors, TAnyComponentCtors>[] = [];

/**
* This fires right after the component is added
*/
public entityAdded$ = new Observable<Entity<ComponentInstance<TKnownComponentCtors>>>();
public entityAdded$ = new Observable<QueryEntity<TAllComponentCtors, TAnyComponentCtors>>();
/**
* This fires right before the component is actually removed from the entity, it will still be available for cleanup purposes
*/
public entityRemoved$ = new Observable<Entity<ComponentInstance<TKnownComponentCtors>>>();
public entityRemoved$ = new Observable<QueryEntity<TAllComponentCtors, TAnyComponentCtors>>();

constructor(public readonly requiredComponents: TKnownComponentCtors[]) {
if (requiredComponents.length === 0) {
throw new Error('Cannot create query without components');
public readonly filter = {
components: {
all: new Set<TAllComponentCtors>(),
any: new Set<TAnyComponentCtors>(),
not: new Set<ComponentCtor<Component>>()
},
tags: {
all: new Set<string>(),
any: new Set<string>(),
not: new Set<string>()
}
for (const type of requiredComponents) {
this.components.add(type);
};

constructor(params: TAllComponentCtors[] | QueryParams<TAllComponentCtors, TAnyComponentCtors>) {
if (Array.isArray(params)) {
params = { components: { all: params } } as QueryParams<TAllComponentCtors, TAnyComponentCtors>;
}

this.id = Query.createId(requiredComponents);
this.filter.components.all = new Set(params.components?.all ?? []);
this.filter.components.any = new Set(params.components?.any ?? []);
this.filter.components.not = new Set(params.components?.not ?? []);
this.filter.tags.all = new Set(params.tags?.all ?? []);
this.filter.tags.any = new Set(params.tags?.any ?? []);
this.filter.tags.not = new Set(params.tags?.not ?? []);

this.id = Query.createId(params);
}

static createId(requiredComponents: Function[]) {
static createId(params: Function[] | QueryParams<any, any>) {
// TODO what happens if a user defines the same type name as a built in type
// ! TODO this could be dangerous depending on the bundler's settings for names
// Maybe some kind of hash function is better here?
return requiredComponents
.slice()
.map((c) => c.name)
if (Array.isArray(params)) {
params = { components: { all: params } } as QueryParams<any, any>;
}

const anyComponents = params.components?.any ? `any_${Query.hashComponents(new Set(params.components?.any))}` : '';
const allComponents = params.components?.all ? `all_${Query.hashComponents(new Set(params.components?.all))}` : '';
const notComponents = params.components?.not ? `not_${Query.hashComponents(new Set(params.components?.not))}` : '';

const anyTags = params.tags?.any ? `any_${Query.hashTags(new Set(params.tags?.any))}` : '';
const allTags = params.tags?.all ? `all_${Query.hashTags(new Set(params.tags?.all))}` : '';
const notTags = params.tags?.not ? `not_${Query.hashTags(new Set(params.tags?.not))}` : '';

return [anyComponents, allComponents, notComponents, anyTags, allTags, notTags].filter(Boolean).join('-');
}

static hashTags(set: Set<string>) {
return Array.from(set)
.map((t) => `t_${t}`)
.sort()
.join('-');
}

static hashComponents(set: Set<ComponentCtor<Component>>) {
return Array.from(set)
.map((c) => `c_${c.name}`)
.sort()
.join('-');
}

matches(entity: Entity): boolean {
// Components
// check if entity has all components
for (const component of this.filter.components.all) {
if (!entity.has(component)) {
return false;
}
}

// check if entity has any components
if (this.filter.components.any.size > 0) {
let found = false;
for (const component of this.filter.components.any) {
if (entity.has(component)) {
found = true;
break;
}
}

if (!found) {
return false;
}
}

// check if entity has none of the components
for (const component of this.filter.components.not) {
if (entity.has(component)) {
return false;
}
}

// Tags
// check if entity has all tags
for (const tag of this.filter.tags.all) {
if (!entity.hasTag(tag)) {
return false;
}
}

// check if entity has any tags
if (this.filter.tags.any.size > 0) {
let found = false;
for (const tag of this.filter.tags.any) {
if (entity.hasTag(tag)) {
found = true;
break;
}
}

if (!found) {
return false;
}
}

// check if entity has none of the tags
for (const tag of this.filter.tags.not) {
if (entity.hasTag(tag)) {
return false;
}
}

return true;
}

/**
* Potentially adds an entity to a query index, returns true if added, false if not
* @param entity
*/
checkAndAdd(entity: Entity) {
if (!this.entities.includes(entity) && entity.hasAll(Array.from(this.components))) {
if (this.matches(entity) && !this.entities.includes(entity)) {
this.entities.push(entity);
this.entityAdded$.notifyAll(entity);
return true;
Expand All @@ -72,10 +207,10 @@ export class Query<TKnownComponentCtors extends ComponentCtor<Component> = never
* Returns a list of entities that match the query
* @param sort Optional sorting function to sort entities returned from the query
*/
public getEntities(sort?: (a: Entity, b: Entity) => number): Entity<ComponentInstance<TKnownComponentCtors>>[] {
public getEntities(sort?: (a: Entity, b: Entity) => number): QueryEntity<TAllComponentCtors, TAnyComponentCtors>[] {
if (sort) {
this.entities.sort(sort);
}
return this.entities;
return this.entities as QueryEntity<TAllComponentCtors, TAnyComponentCtors>[];
}
}
21 changes: 12 additions & 9 deletions src/engine/EntityComponentSystem/QueryManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Entity } from './Entity';
import { Query } from './Query';
import { Query, QueryParams } from './Query';
import { Component, ComponentCtor } from './Component';
import { World } from './World';
import { TagQuery } from './TagQuery';
Expand All @@ -8,10 +8,10 @@ import { TagQuery } from './TagQuery';
* The query manager is responsible for updating all queries when entities/components change
*/
export class QueryManager {
private _queries = new Map<string, Query<any>>();
private _queries = new Map<string, Query<any, any>>();
private _addComponentHandlers = new Map<Entity, (c: Component) => any>();
private _removeComponentHandlers = new Map<Entity, (c: Component) => any>();
private _componentToQueriesIndex = new Map<ComponentCtor<any>, Query<any>[]>();
private _componentToQueriesIndex = new Map<ComponentCtor<any>, Query<any, any>[]>();

private _tagQueries = new Map<string, TagQuery<any>>();
private _addTagHandlers = new Map<Entity, (tag: string) => any>();
Expand All @@ -20,21 +20,24 @@ export class QueryManager {

constructor(private _world: World) {}

public createQuery<TKnownComponentCtors extends ComponentCtor<Component>>(
requiredComponents: TKnownComponentCtors[]
): Query<TKnownComponentCtors> {
const id = Query.createId(requiredComponents);
public createQuery<
TKnownComponentCtors extends ComponentCtor<Component> = never,
TAnyComponentCtors extends ComponentCtor<Component> = never
>(
params: TKnownComponentCtors[] | QueryParams<TKnownComponentCtors, TAnyComponentCtors>
): Query<TKnownComponentCtors, TAnyComponentCtors> {
const id = Query.createId(params);
if (this._queries.has(id)) {
// short circuit if query is already created
return this._queries.get(id) as Query<TKnownComponentCtors>;
}

const query = new Query(requiredComponents);
const query = new Query<TKnownComponentCtors, TAnyComponentCtors>(params);

this._queries.set(query.id, query);

// index maintenance
for (const component of requiredComponents) {
for (const component of [...query.filter.components.all, ...query.filter.components.any, ...query.filter.components.not]) {
const queries = this._componentToQueriesIndex.get(component);
if (!queries) {
this._componentToQueriesIndex.set(component, [query]);
Expand Down
9 changes: 5 additions & 4 deletions src/engine/EntityComponentSystem/World.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Logger } from '../Util/Log';
import { Component, ComponentCtor } from './Component';
import { Entity } from './Entity';
import { EntityManager } from './EntityManager';
import { Query } from './Query';
import { Query, QueryParams } from './Query';
import { QueryManager } from './QueryManager';
import { System, SystemType } from './System';
import { SystemCtor, SystemManager, isSystemConstructor } from './SystemManager';
Expand All @@ -26,10 +26,11 @@ export class World {

/**
* Query the ECS world for entities that match your components
* @param requiredTypes
*/
query<TKnownComponentCtors extends ComponentCtor<Component>>(requiredTypes: TKnownComponentCtors[]): Query<TKnownComponentCtors> {
return this.queryManager.createQuery(requiredTypes);
query<TKnownComponentCtors extends ComponentCtor<Component> = never, TAnyComponentCtors extends ComponentCtor<Component> = never>(
params: TKnownComponentCtors[] | QueryParams<TKnownComponentCtors, TAnyComponentCtors>
): Query<TKnownComponentCtors, TAnyComponentCtors> {
return this.queryManager.createQuery(params);
}

queryTags<TKnownTags extends string>(requiredTags: TKnownTags[]): TagQuery<TKnownTags> {
Expand Down
2 changes: 0 additions & 2 deletions src/spec/CollisionShapeSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,7 +840,6 @@ describe('Collision Shape', () => {
it('can be drawn with actor', async () => {
const polygonActor = new ex.Actor({
pos: new ex.Vector(150, 100),
color: ex.Color.Blue,
collider: ex.Shape.Polygon([new ex.Vector(0, -100), new ex.Vector(-100, 50), new ex.Vector(100, 50)])
});

Expand Down Expand Up @@ -1077,7 +1076,6 @@ describe('Collision Shape', () => {
it('can be drawn with actor', async () => {
const edgeActor = new ex.Actor({
pos: new ex.Vector(150, 100),
color: ex.Color.Blue,
collider: ex.Shape.Edge(ex.Vector.Zero, new ex.Vector(300, 300))
});

Expand Down
Loading
Loading