• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 15926884062

27 Jun 2025 12:59PM UTC coverage: 87.851% (-0.02%) from 87.869%
15926884062

push

github

web-flow
feat: support any, all and not component/tag filters on Query (#3380)

Improves `ex.Query` to support "any" and "not" filters for components, as well as all/any/not tags filters. The intention is for this to replace `ex.TagQuery`, but that is not (yet) in this PR.

With this change, this would much easily allow systems to exclude entities with tags. For example, GraphicsSystem could now exclude any Entity with an `ex.offscreen` tag instead of manually checking in its update. This could also be used for #3076 by adding a tag to an Entity while paused that excludes it from systems.

I also removed the restriction of throwing on empty component queries, as I think it could make sense to have a system that applies for every Entity. I can change this, though.

--- 

There's also some code quality changes here. 

- I added an eslint rule to prevent `fit` tests (as I've accidentally committed those before)
- an easy way to enable console logs in karma by doing `CAPTURE_CONSOLE=true npm run test` as I'd previously have to edit the karma config file.
- fixed some ts errors in collider tests

===:clipboard: PR Checklist :clipboard:===

- [ ] :pushpin: issue exists in github for these changes
- [x] :microscope: existing tests still pass
- [x] :see_no_evil: code conforms to the [style guide](https://github.com/excaliburjs/Excalibur/blob/main/STYLEGUIDE.md)
- [x] :triangular_ruler: new tests written and passing / old tests updated with new scenario(s)
- [x] :page_facing_up: changelog entry added (or not needed)

==================

<!-- If you're closing an issue with this pull request, or contributing a significant change, please include your changes in the appropriate section of CHANGELOG.md as outlined in https://github.com/excaliburjs/Excalibur/blob/main/.github/CONTRIBUTING.md#creating-a-pull-request. -->

<!--Please format your pull request title according to our commit message styleguide: https://github.com/excaliburjs/Excalibur/blob/main/.github/CONTRIBUTI... (continued)

5083 of 7051 branches covered (72.09%)

59 of 63 new or added lines in 3 files covered. (93.65%)

4 existing lines in 1 file now uncovered.

13710 of 15606 relevant lines covered (87.85%)

25170.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.54
/src/engine/EntityComponentSystem/Query.ts
1
import type { Entity } from './Entity';
2
import { Observable } from '../Util/Observable';
3
import type { Component, ComponentCtor } from '../EntityComponentSystem/Component';
4

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

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

12
export interface QueryParams<
13
  TKnownComponentCtors extends ComponentCtor<Component> = never,
14
  TAnyComponentCtors extends ComponentCtor<Component> = never
15
> {
16
  components?: {
17
    all?: TKnownComponentCtors[];
18
    any?: TAnyComponentCtors[];
19
    not?: ComponentCtor<Component>[];
20
  };
21
  tags?: {
22
    all?: string[];
23
    any?: string[];
24
    not?: string[];
25
  };
26
}
27

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

35
/**
36
 * Represents query for entities that match a list of types that is cached and observable
37
 *
38
 * Queries can be strongly typed by supplying a type union in the optional type parameter
39
 * ```typescript
40
 * const queryAB = new ex.Query<ComponentTypeA | ComponentTypeB>(['A', 'B']);
41
 * ```
42
 */
43
export class Query<
44
  TAllComponentCtors extends ComponentCtor<Component> = never,
45
  TAnyComponentCtors extends ComponentCtor<Component> = never
46
> {
47
  public readonly id: string;
48

49
  public entities: QueryEntity<TAllComponentCtors, TAnyComponentCtors>[] = [];
9,004✔
50

51
  /**
52
   * This fires right after the component is added
53
   */
54
  public entityAdded$ = new Observable<QueryEntity<TAllComponentCtors, TAnyComponentCtors>>();
9,004✔
55
  /**
56
   * This fires right before the component is actually removed from the entity, it will still be available for cleanup purposes
57
   */
58
  public entityRemoved$ = new Observable<QueryEntity<TAllComponentCtors, TAnyComponentCtors>>();
9,004✔
59

60
  public readonly filter = {
9,004✔
61
    components: {
62
      all: new Set<TAllComponentCtors>(),
63
      any: new Set<TAnyComponentCtors>(),
64
      not: new Set<ComponentCtor<Component>>()
65
    },
66
    tags: {
67
      all: new Set<string>(),
68
      any: new Set<string>(),
69
      not: new Set<string>()
70
    }
71
  };
72

73
  constructor(params: TAllComponentCtors[] | QueryParams<TAllComponentCtors, TAnyComponentCtors>) {
74
    if (Array.isArray(params)) {
9,004✔
75
      params = { components: { all: params } } as QueryParams<TAllComponentCtors, TAnyComponentCtors>;
9,000✔
76
    }
77

78
    this.filter.components.all = new Set(params.components?.all ?? []);
9,004✔
79
    this.filter.components.any = new Set(params.components?.any ?? []);
9,004✔
80
    this.filter.components.not = new Set(params.components?.not ?? []);
9,004✔
81
    this.filter.tags.all = new Set(params.tags?.all ?? []);
9,004!
82
    this.filter.tags.any = new Set(params.tags?.any ?? []);
9,004✔
83
    this.filter.tags.not = new Set(params.tags?.not ?? []);
9,004✔
84

85
    this.id = Query.createId(params);
9,004✔
86
  }
87

88
  static createId(params: Function[] | QueryParams<any, any>) {
89
    // TODO what happens if a user defines the same type name as a built in type
90
    // ! TODO this could be dangerous depending on the bundler's settings for names
91
    // Maybe some kind of hash function is better here?
92
    if (Array.isArray(params)) {
19,538✔
93
      params = { components: { all: params } } as QueryParams<any, any>;
10,534✔
94
    }
95

96
    const anyComponents = params.components?.any ? `any_${Query.hashComponents(new Set(params.components?.any))}` : '';
19,538!
97
    const allComponents = params.components?.all ? `all_${Query.hashComponents(new Set(params.components?.all))}` : '';
19,538!
98
    const notComponents = params.components?.not ? `not_${Query.hashComponents(new Set(params.components?.not))}` : '';
19,538!
99

100
    const anyTags = params.tags?.any ? `any_${Query.hashTags(new Set(params.tags?.any))}` : '';
19,538!
101
    const allTags = params.tags?.all ? `all_${Query.hashTags(new Set(params.tags?.all))}` : '';
19,538!
102
    const notTags = params.tags?.not ? `not_${Query.hashTags(new Set(params.tags?.not))}` : '';
19,538!
103

104
    return [anyComponents, allComponents, notComponents, anyTags, allTags, notTags].filter(Boolean).join('-');
19,538✔
105
  }
106

107
  static hashTags(set: Set<string>) {
108
    return Array.from(set)
2✔
109
      .map((t) => `t_${t}`)
2✔
110
      .sort()
111
      .join('-');
112
  }
113

114
  static hashComponents(set: Set<ComponentCtor<Component>>) {
115
    return Array.from(set)
19,538✔
116
      .map((c) => `c_${c.name}`)
33,840✔
117
      .sort()
118
      .join('-');
119
  }
120

121
  matches(entity: Entity): boolean {
122
    // Components
123
    // check if entity has all components
124
    for (const component of this.filter.components.all) {
13,562✔
125
      if (!entity.has(component)) {
23,203✔
126
        return false;
6,244✔
127
      }
128
    }
129

130
    // check if entity has any components
131
    if (this.filter.components.any.size > 0) {
7,318✔
132
      let found = false;
2✔
133
      for (const component of this.filter.components.any) {
2✔
134
        if (entity.has(component)) {
3✔
135
          found = true;
2✔
136
          break;
2✔
137
        }
138
      }
139

140
      if (!found) {
2!
NEW
141
        return false;
×
142
      }
143
    }
144

145
    // check if entity has none of the components
146
    for (const component of this.filter.components.not) {
7,318✔
147
      if (entity.has(component)) {
2✔
148
        return false;
1✔
149
      }
150
    }
151

152
    // Tags
153
    // check if entity has all tags
154
    for (const tag of this.filter.tags.all) {
7,317✔
NEW
155
      if (!entity.hasTag(tag)) {
×
NEW
156
        return false;
×
157
      }
158
    }
159

160
    // check if entity has any tags
161
    if (this.filter.tags.any.size > 0) {
7,317✔
162
      let found = false;
2✔
163
      for (const tag of this.filter.tags.any) {
2✔
164
        if (entity.hasTag(tag)) {
2!
165
          found = true;
2✔
166
          break;
2✔
167
        }
168
      }
169

170
      if (!found) {
2!
NEW
171
        return false;
×
172
      }
173
    }
174

175
    // check if entity has none of the tags
176
    for (const tag of this.filter.tags.not) {
7,317✔
177
      if (entity.hasTag(tag)) {
2✔
178
        return false;
1✔
179
      }
180
    }
181

182
    return true;
7,316✔
183
  }
184

185
  /**
186
   * Potentially adds an entity to a query index, returns true if added, false if not
187
   * @param entity
188
   */
189
  checkAndAdd(entity: Entity) {
190
    if (this.matches(entity) && !this.entities.includes(entity)) {
13,560✔
191
      this.entities.push(entity);
7,302✔
192
      this.entityAdded$.notifyAll(entity);
7,302✔
193
      return true;
7,302✔
194
    }
195
    return false;
6,258✔
196
  }
197

198
  removeEntity(entity: Entity) {
199
    const index = this.entities.indexOf(entity);
287✔
200
    if (index > -1) {
287✔
201
      this.entities.splice(index, 1);
182✔
202
      this.entityRemoved$.notifyAll(entity);
182✔
203
    }
204
  }
205

206
  /**
207
   * Returns a list of entities that match the query
208
   * @param sort Optional sorting function to sort entities returned from the query
209
   */
210
  public getEntities(sort?: (a: Entity, b: Entity) => number): QueryEntity<TAllComponentCtors, TAnyComponentCtors>[] {
211
    if (sort) {
30!
212
      this.entities.sort(sort);
×
213
    }
214
    return this.entities as QueryEntity<TAllComponentCtors, TAnyComponentCtors>[];
30✔
215
  }
216
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc