diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0c7016..9dc6b820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,29 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Support for disabling integration for all offscreen entities, or on a per entity basis + + ```typescript + // for all entities + const game = new ex.Engine({ + physics: { + integration: { + // defaults to false + onScreenOnly: true + }, + }); + + // per entity (only if engine is off) + const actor = new ex.Actor(...); + actor.get(MotionComponent).integration.onScreenOnly = true; + ``` + - DX: Support for [Visual Studio Code Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) for an out-of-the-box contribution dev environment - DX: Support for [Vitest UI](https://vitest.dev/guide/ui.html) for browser testing ### Fixed +- Fixed issue where not(tags) and not(component) queries weren't updating in the querymanager - Fixed Tilemap/Isometric map pointer performance on moderate to large maps, we changed the strategy to only consider tiles under the pointer instead of try to do sorted dispatch on NxM tiles. - Fixed issue that caused coroutines to not automatically discover the engine scheduler when inside an async lifecycle sometimes. This is because of the stack replacement issue of async/await the context reverts too soon. diff --git a/package-lock.json b/package-lock.json index 6d4b0fdd..80f2faa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12507,9 +12507,9 @@ } }, "node_modules/tar-fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", - "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index 1cd9cb9a..8936f79b 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -65,6 +65,9 @@ var game = new ex.Engine({ garbageCollection: true, uvPadding: 0, physics: { + integration: { + onScreenOnly: true + }, colliders: { compositeStrategy: 'together' }, diff --git a/src/engine/Collision/MotionSystem.ts b/src/engine/Collision/MotionSystem.ts index 8380b3bf..909efa98 100644 --- a/src/engine/Collision/MotionSystem.ts +++ b/src/engine/Collision/MotionSystem.ts @@ -19,20 +19,35 @@ export class MotionSystem extends System { public physics: PhysicsWorld ) { super(); - physics.$configUpdate.subscribe(() => (this._physicsConfigDirty = true)); - this.query = this.world.query([TransformComponent, MotionComponent]); + this.query = this.world.query({ + components: { + all: [TransformComponent, MotionComponent] + }, + tags: { + not: this.physics.config.integration.onScreenOnly ? ['ex.offscreen'] : [] + } + }); + + physics.$configUpdate.subscribe(() => { + this._physicsConfigDirty = true; + }); } update(elapsed: number): void { let transform: TransformComponent; let motion: MotionComponent; const entities = this.query.entities; - const substep = this.physics.config.substep; + const config = this.physics.config; + const substep = config.substep; for (let i = 0; i < entities.length; i++) { transform = entities[i].get(TransformComponent); motion = entities[i].get(MotionComponent); + if (motion.integration.onScreenOnly && entities[i].hasTag('ex.offscreen')) { + continue; + } + const optionalBody = entities[i].get(BodyComponent); if (this._physicsConfigDirty && optionalBody) { optionalBody.updatePhysicsConfig(this.physics.config.bodies); @@ -56,7 +71,17 @@ export class MotionSystem extends System { // Update transform and motion based on Euler linear algebra EulerIntegrator.integrate(transform, motion, totalAcc, elapsed / substep); } - this._physicsConfigDirty = false; + if (this._physicsConfigDirty) { + this._physicsConfigDirty = false; + this.query = this.world.query({ + components: { + all: [TransformComponent, MotionComponent] + }, + tags: { + not: this.physics.config.integration.onScreenOnly ? ['ex.offscreen'] : [] + } + }); + } } captureOldTransformWithChildren(entity: Entity) { diff --git a/src/engine/Collision/PhysicsConfig.ts b/src/engine/Collision/PhysicsConfig.ts index 19d4c2f0..a115f7ad 100644 --- a/src/engine/Collision/PhysicsConfig.ts +++ b/src/engine/Collision/PhysicsConfig.ts @@ -35,6 +35,15 @@ export interface PhysicsConfig { * Excalibur physics simulation is enabled */ enabled?: boolean; + /** + * Configure integration systems + */ + integration?: { + /** + * Integrate on screen only entities, defaults to false meaning all entities are integrated + */ + onScreenOnly?: boolean; + }; /** * Configure gravity that applies to all {@apilink CollisionType.Active} bodies. * @@ -220,6 +229,9 @@ export interface PhysicsConfig { export const getDefaultPhysicsConfig: () => DeepRequired = () => ({ enabled: true, + integration: { + onScreenOnly: false + }, gravity: vec(0, 0).clone(), solver: SolverStrategy.Arcade, substep: 1, diff --git a/src/engine/EntityComponentSystem/Components/MotionComponent.ts b/src/engine/EntityComponentSystem/Components/MotionComponent.ts index a1c156a5..69780157 100644 --- a/src/engine/EntityComponentSystem/Components/MotionComponent.ts +++ b/src/engine/EntityComponentSystem/Components/MotionComponent.ts @@ -73,4 +73,16 @@ export class MotionComponent extends Component { * Inertia can be thought of as the resistance to motion */ public inertia: number = 1; + + /** + * Configure per-entity integration systems + */ + public integration: { + /** + * Integrate on screen only entities, defaults to false meaning all entities are integrated + */ + onScreenOnly: boolean; + } = { + onScreenOnly: false + }; } diff --git a/src/engine/EntityComponentSystem/Entity.ts b/src/engine/EntityComponentSystem/Entity.ts index a95219c0..3bfdf17f 100644 --- a/src/engine/EntityComponentSystem/Entity.ts +++ b/src/engine/EntityComponentSystem/Entity.ts @@ -117,7 +117,7 @@ export class Entity implements OnIniti */ public readonly components = new Map(); public componentValues: Component[] = []; - private _componentsToRemove: ComponentCtor[] = []; + public _componentsToRemove: ComponentCtor[] = []; constructor(options: EntityOptions); constructor(components?: TKnownComponents[], name?: string); diff --git a/src/engine/EntityComponentSystem/Query.ts b/src/engine/EntityComponentSystem/Query.ts index 11357779..994b5373 100644 --- a/src/engine/EntityComponentSystem/Query.ts +++ b/src/engine/EntityComponentSystem/Query.ts @@ -46,14 +46,23 @@ export class Query< > { public readonly id: string; - public entities: QueryEntity[] = []; + private _entities: QueryEntity[] = []; + public get entities() { + if (this._dirty) { + this._entities = Array.from(this.entitiesSet); + this._dirty = false; + } + return this._entities; + } + + public entitiesSet: Set> = new Set(); /** - * This fires right after the component is added + * This fires right after the component or tag is added */ public entityAdded$ = new Observable>(); /** - * This fires right before the component is actually removed from the entity, it will still be available for cleanup purposes + * This fires right before the component or tag is actually removed from the entity, it will still be available for cleanup purposes */ public entityRemoved$ = new Observable>(); @@ -69,6 +78,7 @@ export class Query< not: new Set() } }; + private _dirty: boolean = false; constructor(params: TAllComponentCtors[] | QueryParams) { if (Array.isArray(params)) { @@ -118,15 +128,64 @@ export class Query< .join('-'); } - matches(entity: Entity): boolean { - // Components + matchesNotFilter(entity: Entity, removedComponent?: ComponentCtor): boolean { + for (const component of this.filter.components.not) { + if (removedComponent === component) { + continue; + } + if (entity.has(component)) { + return true; + } + } + + // check if entity has none of the tags + for (const tag of this.filter.tags.not) { + if (entity.hasTag(tag)) { + return true; + } + } + return false; + } + + matches(entity: Entity, removedComponent?: ComponentCtor): boolean { + // Components & Tags + + // IMPORTANT: Check NOT conditions first, all exclusions first + + // check if entity has none of the components + for (const component of this.filter.components.not) { + if (removedComponent === component) { + continue; + } + if (entity.has(component)) { + return false; + } + } + + // check if entity has none of the tags + for (const tag of this.filter.tags.not) { + if (entity.hasTag(tag)) { + return false; + } + } + // check if entity has all components for (const component of this.filter.components.all) { + if (component === removedComponent) { + return false; + } if (!entity.has(component)) { return false; } } + // 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 components if (this.filter.components.any.size > 0) { let found = false; @@ -142,21 +201,6 @@ export class Query< } } - // 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; @@ -172,33 +216,43 @@ export class Query< } } - // 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 + * Potentially adds or removes an entity from a query index, returns true if added, false if not added or was removed. * @param entity + * @param [optional] removedComponent that should be treated as removed from the entity */ - checkAndAdd(entity: Entity) { - if (this.matches(entity) && !this.entities.includes(entity)) { - this.entities.push(entity); + checkAndModify(entity: Entity, removedComponent?: ComponentCtor): boolean { + const inCurrentQuery = this.entitiesSet.has(entity); + + if (inCurrentQuery && this.matchesNotFilter(entity, removedComponent)) { + this.removeEntity(entity); + return false; + } + + const matches = this.matches(entity, removedComponent); + + if (inCurrentQuery && !matches) { + this.removeEntity(entity); + return false; + } + + if (!inCurrentQuery && matches) { + this._dirty = true; + this.entitiesSet.add(entity); this.entityAdded$.notifyAll(entity); return true; } + return false; } removeEntity(entity: Entity) { - const index = this.entities.indexOf(entity); - if (index > -1) { - this.entities.splice(index, 1); + const removed = this.entitiesSet.delete(entity); + if (removed) { + this._dirty = true; this.entityRemoved$.notifyAll(entity); } } diff --git a/src/engine/EntityComponentSystem/QueryManager.ts b/src/engine/EntityComponentSystem/QueryManager.ts index 8edf43ba..728dc543 100644 --- a/src/engine/EntityComponentSystem/QueryManager.ts +++ b/src/engine/EntityComponentSystem/QueryManager.ts @@ -3,7 +3,6 @@ import type { QueryParams } from './Query'; import { Query } from './Query'; import type { Component, ComponentCtor } from './Component'; import type { World } from './World'; -import { TagQuery } from './TagQuery'; /** * The query manager is responsible for updating all queries when entities/components change @@ -12,12 +11,12 @@ export class QueryManager { private _queries = new Map>(); private _addComponentHandlers = new Map any>(); private _removeComponentHandlers = new Map any>(); + private _componentToQueriesIndex = new Map, Query[]>(); + private _tagToQueriesIndex = new Map[]>(); - private _tagQueries = new Map>(); private _addTagHandlers = new Map any>(); private _removeTagHandlers = new Map any>(); - private _tagToQueriesIndex = new Map[]>(); constructor(private _world: World) {} @@ -38,6 +37,7 @@ export class QueryManager { this._queries.set(query.id, query); // index maintenance + // components for (const component of [...query.filter.components.all, ...query.filter.components.any, ...query.filter.components.not]) { const queries = this._componentToQueriesIndex.get(component); if (!queries) { @@ -46,28 +46,10 @@ export class QueryManager { queries.push(query); } } - - for (const entity of this._world.entities) { - this.addEntity(entity); - } - - return query; - } - - public createTagQuery(requiredTags: TKnownTags[]): TagQuery { - const id = TagQuery.createId(requiredTags); - if (this._tagQueries.has(id)) { - // short circuit if query is already created - return this._tagQueries.get(id) as TagQuery; - } - - const query = new TagQuery(requiredTags); - - this._tagQueries.set(query.id, query); - - // index maintenance - for (const tag of requiredTags) { + // tags + for (const tag of [...query.filter.tags.all, ...query.filter.tags.any, ...query.filter.tags.not]) { const queries = this._tagToQueriesIndex.get(tag); + if (!queries) { this._tagToQueriesIndex.set(tag, [query]); } else { @@ -118,11 +100,9 @@ export class QueryManager { this._removeTagHandlers.set(entity, removeTag); for (const query of this._queries.values()) { - query.checkAndAdd(entity); - } - for (const tagQuery of this._tagQueries.values()) { - tagQuery.checkAndAdd(entity); + query.checkAndModify(entity); } + entity.componentAdded$.subscribe(addComponent); entity.componentRemoved$.subscribe(removeComponent); entity.tagAdded$.subscribe(addTag); @@ -152,9 +132,6 @@ export class QueryManager { // Handle tags const addTag = this._addTagHandlers.get(entity); const removeTag = this._removeTagHandlers.get(entity); - for (const tagQuery of this._tagQueries.values()) { - tagQuery.removeEntity(entity); - } if (addTag) { entity.tagAdded$.unsubscribe(addTag); @@ -174,7 +151,7 @@ export class QueryManager { addComponent(entity: Entity, component: Component) { const queries = this._componentToQueriesIndex.get(component.constructor as ComponentCtor) ?? []; for (const query of queries) { - query.checkAndAdd(entity); + query.checkAndModify(entity); } } @@ -186,7 +163,8 @@ export class QueryManager { removeComponent(entity: Entity, component: Component) { const queries = this._componentToQueriesIndex.get(component.constructor as ComponentCtor) ?? []; for (const query of queries) { - query.removeEntity(entity); + // so this should not be called + query.checkAndModify(entity, component.constructor as ComponentCtor); } } @@ -198,7 +176,7 @@ export class QueryManager { addTag(entity: Entity, tag: string) { const queries = this._tagToQueriesIndex.get(tag) ?? []; for (const query of queries) { - query.checkAndAdd(entity); + query.checkAndModify(entity); } } @@ -211,6 +189,7 @@ export class QueryManager { const queries = this._tagToQueriesIndex.get(tag) ?? []; for (const query of queries) { query.removeEntity(entity); + query.checkAndModify(entity); } } } diff --git a/src/engine/EntityComponentSystem/TagQuery.ts b/src/engine/EntityComponentSystem/TagQuery.ts index ea537e22..906c0784 100644 --- a/src/engine/EntityComponentSystem/TagQuery.ts +++ b/src/engine/EntityComponentSystem/TagQuery.ts @@ -1,6 +1,9 @@ import { Observable } from '../Util/Observable'; import type { Entity } from './Entity'; +/** + * @deprecated + */ export class TagQuery { public readonly id: string; public tags = new Set(); diff --git a/src/engine/EntityComponentSystem/World.ts b/src/engine/EntityComponentSystem/World.ts index 5348d896..35f284b8 100644 --- a/src/engine/EntityComponentSystem/World.ts +++ b/src/engine/EntityComponentSystem/World.ts @@ -8,7 +8,6 @@ import { QueryManager } from './QueryManager'; import { System, SystemType } from './System'; import type { SystemCtor } from './SystemManager'; import { SystemManager, isSystemConstructor } from './SystemManager'; -import type { TagQuery } from './TagQuery'; /** * The World is a self-contained entity component system for a particular context. @@ -34,8 +33,11 @@ export class World { return this.queryManager.createQuery(params); } - queryTags(requiredTags: TKnownTags[]): TagQuery { - return this.queryManager.createTagQuery(requiredTags); + /** + * @deprecated + */ + queryTags(requiredTags: TKnownTags[]): Query { + return this.queryManager.createQuery({ tags: { all: requiredTags } }) as any; } /** diff --git a/src/spec/vitest/ActorSpec.ts b/src/spec/vitest/ActorSpec.ts index 2f0304e7..8fe885aa 100644 --- a/src/spec/vitest/ActorSpec.ts +++ b/src/spec/vitest/ActorSpec.ts @@ -637,7 +637,7 @@ describe('A game actor', () => { expect(childActor.pos.y).toBe(50); actor.addChild(childActor); - actionSystem.query.checkAndAdd(actor); + actionSystem.query.checkAndModify(actor); actor.actions.moveTo(10, 15, 1000); actionSystem.update(1000); diff --git a/src/spec/vitest/DebugSystemSpec.ts b/src/spec/vitest/DebugSystemSpec.ts index 4aceafc5..513bb5b5 100644 --- a/src/spec/vitest/DebugSystemSpec.ts +++ b/src/spec/vitest/DebugSystemSpec.ts @@ -45,7 +45,7 @@ describe('DebugSystem', () => { await (engine.graphicsContext.debug as any)._debugText.load(); const entity = new ex.Entity([new ex.TransformComponent(), new ex.ColliderComponent()]); - debugSystem.query.checkAndAdd(entity); + debugSystem.query.checkAndModify(entity); engine.debug.collider.showAll = true; @@ -68,7 +68,7 @@ describe('DebugSystem', () => { actor.id = 0; engine.debug.transform.showAll = true; engine.debug.entity.showAll = true; - debugSystem.query.checkAndAdd(actor); + debugSystem.query.checkAndModify(actor); debugSystem.update(); engine.graphicsContext.flush(); @@ -89,7 +89,7 @@ describe('DebugSystem', () => { actor.id = 0; actor.vel = ex.vec(100, 0); actor.acc = ex.vec(100, -100); - debugSystem.query.checkAndAdd(actor); + debugSystem.query.checkAndModify(actor); engine.debug.motion.showAll = true; engine.debug.collider.showGeometry = true; engine.debug.collider.geometryLineWidth = 2; @@ -113,7 +113,7 @@ describe('DebugSystem', () => { actor.id = 0; actor.vel = ex.vec(100, 0); actor.acc = ex.vec(100, -100); - debugSystem.query.checkAndAdd(actor); + debugSystem.query.checkAndModify(actor); engine.debug.body.showAll = true; debugSystem.update(); @@ -133,7 +133,7 @@ describe('DebugSystem', () => { const actor = new ex.Actor({ name: 'thingy', x: -100 + center.x, y: center.y, width: 50, height: 50, color: ex.Color.Yellow }); actor.id = 0; - debugSystem.query.checkAndAdd(actor); + debugSystem.query.checkAndModify(actor); engine.debug.entity.showId = true; engine.debug.collider.showAll = true; debugSystem.update(); @@ -155,7 +155,7 @@ describe('DebugSystem', () => { const actor = new ex.Actor({ name: 'thingy', x: -100 + center.x, y: center.y, width: 50, height: 50, color: ex.Color.Yellow }); actor.collider.useCompositeCollider([ex.Shape.Circle(50), ex.Shape.Box(150, 20), ex.Shape.Box(10, 150)]); actor.id = 0; - debugSystem.query.checkAndAdd(actor); + debugSystem.query.checkAndModify(actor); engine.debug.collider.showAll = true; engine.debug.collider.geometryLineWidth = 3; debugSystem.update(); @@ -177,7 +177,7 @@ describe('DebugSystem', () => { const actor = new ex.Actor({ name: 'thingy', x: -100 + center.x, y: center.y, width: 50, height: 50 }); actor.graphics.use(new ex.Rectangle({ width: 200, height: 100, color: ex.Color.Red })); actor.id = 0; - debugSystem.query.checkAndAdd(actor); + debugSystem.query.checkAndModify(actor); engine.debug.collider.showBounds = false; engine.debug.collider.showGeometry = false; engine.debug.collider.showOwner = false; @@ -203,7 +203,7 @@ describe('DebugSystem', () => { ctx.drawCircle(ex.vec(250, 250), 100, ex.Color.Blue); }) ]); - debugSystem.query.checkAndAdd(entity); + debugSystem.query.checkAndModify(entity); debugSystem.update(); engine.graphicsContext.flush(); @@ -230,7 +230,7 @@ describe('DebugSystem', () => { }); tilemap.tiles[0].solid = true; tilemap.update(engine, 1); - debugSystem.query.checkAndAdd(tilemap); + debugSystem.query.checkAndModify(tilemap); debugSystem.update(); engine.graphicsContext.flush(); diff --git a/src/spec/vitest/GraphicsSystemSpec.ts b/src/spec/vitest/GraphicsSystemSpec.ts index 1223ae40..00458218 100644 --- a/src/spec/vitest/GraphicsSystemSpec.ts +++ b/src/spec/vitest/GraphicsSystemSpec.ts @@ -100,8 +100,8 @@ describe('A Graphics ECS System', () => { entities.push(offscreen); engine.graphicsContext.clear(); - entities.forEach((e) => offscreenSystem.query.checkAndAdd(e)); - entities.forEach((e) => sut.query.checkAndAdd(e)); + entities.forEach((e) => offscreenSystem.query.checkAndModify(e)); + entities.forEach((e) => sut.query.checkAndModify(e)); offscreenSystem.update(); @@ -138,9 +138,9 @@ describe('A Graphics ECS System', () => { }); actor.graphics.opacity = 0.5; - sut.query.checkAndAdd(actor); + sut.query.checkAndModify(actor); - offscreenSystem.query.checkAndAdd(actor); + offscreenSystem.query.checkAndModify(actor); offscreenSystem.update(); engine.graphicsContext.clear(); @@ -173,7 +173,7 @@ describe('A Graphics ECS System', () => { actor.graphics.use(sword.toSprite()); actor.graphics.flipHorizontal = true; - sut.query.checkAndAdd(actor); + sut.query.checkAndModify(actor); offscreenSystem.update(); @@ -207,7 +207,7 @@ describe('A Graphics ECS System', () => { actor.graphics.use(sword.toSprite()); actor.graphics.flipVertical = true; - sut.query.checkAndAdd(actor); + sut.query.checkAndModify(actor); offscreenSystem.update(); @@ -242,7 +242,7 @@ describe('A Graphics ECS System', () => { actor.graphics.flipVertical = true; actor.graphics.flipHorizontal = true; - sut.query.checkAndAdd(actor); + sut.query.checkAndModify(actor); offscreenSystem.update(); @@ -278,7 +278,7 @@ describe('A Graphics ECS System', () => { actor.graphics.flipHorizontal = true; actor.graphics.offset = ex.vec(25, 25); - sut.query.checkAndAdd(actor); + sut.query.checkAndModify(actor); offscreenSystem.update(); @@ -314,7 +314,7 @@ describe('A Graphics ECS System', () => { const graphicsSystem = new ex.GraphicsSystem(game.currentScene.world); graphicsSystem.initialize(game.currentScene.world, game.currentScene); graphicsSystem.preupdate(); - graphicsSystem.query.checkAndAdd(actor); + graphicsSystem.query.checkAndModify(actor); game.currentFrameLagMs = 8; // current lag in a 30 fps frame graphicsSystem.update(30); @@ -352,7 +352,7 @@ describe('A Graphics ECS System', () => { const graphicsSystem = new ex.GraphicsSystem(game.currentScene.world); graphicsSystem.initialize(game.currentScene.world, game.currentScene); graphicsSystem.preupdate(); - graphicsSystem.query.checkAndAdd(actor); + graphicsSystem.query.checkAndModify(actor); game.currentFrameLagMs = 1000 / 30 / 2; // current lag in a 30 fps frame graphicsSystem.update(16); @@ -385,7 +385,7 @@ describe('A Graphics ECS System', () => { const graphicsSystem = new ex.GraphicsSystem(game.currentScene.world); graphicsSystem.initialize(game.currentScene.world, game.currentScene); graphicsSystem.preupdate(); - graphicsSystem.query.checkAndAdd(actor); + graphicsSystem.query.checkAndModify(actor); actor.body.enableFixedUpdateInterpolate = false; game.currentFrameLagMs = 8; // current lag in a 30 fps frame @@ -410,8 +410,8 @@ describe('A Graphics ECS System', () => { child.addComponent(new ex.GraphicsComponent()); parent.addChild(child); - sut.query.checkAndAdd(parent); - sut.query.checkAndAdd(child); + sut.query.checkAndModify(parent); + sut.query.checkAndModify(child); expect(() => sut.update(1)).not.toThrow(); }); diff --git a/src/spec/vitest/OffscreenSystemSpec.ts b/src/spec/vitest/OffscreenSystemSpec.ts index af7ca506..86c71d4e 100644 --- a/src/spec/vitest/OffscreenSystemSpec.ts +++ b/src/spec/vitest/OffscreenSystemSpec.ts @@ -51,7 +51,7 @@ describe('The OffscreenSystem', () => { offscreen.events.on('enterviewport', onscreenSpy); offscreen.events.on('exitviewport', offscreenSpy); - sut.query.checkAndAdd(offscreen); + sut.query.checkAndModify(offscreen); // Should be offscreen sut.update(); diff --git a/src/spec/vitest/QueryManagerSpec.ts b/src/spec/vitest/QueryManagerSpec.ts index caf6fc19..80f272cd 100644 --- a/src/spec/vitest/QueryManagerSpec.ts +++ b/src/spec/vitest/QueryManagerSpec.ts @@ -49,6 +49,39 @@ describe('A QueryManager', () => { expect(queryAB.getEntities(), 'No entities should match').toEqual([]); }); + it('can create tag queries for entities', () => { + const world = new ex.World(null); + const entity1 = new ex.Entity(); + entity1.addTag('A'); + entity1.addTag('B'); + + const entity2 = new ex.Entity(); + entity2.addTag('A'); + + world.entityManager.addEntity(entity1); + world.entityManager.addEntity(entity2); + + // Query for all entities that have type A components + const queryA = world.queryTags(['A']); + // Query for all entities that have type A & B components + const queryAB = world.queryTags(['A', 'B']); + + expect(queryA.getEntities(), 'Both entities have component A').toEqual([entity1, entity2]); + expect(queryAB.getEntities(), 'Only entity1 has both A+B').toEqual([entity1]); + + // Queries update if component change + entity2.addTag('B'); + expect(queryAB.getEntities(), 'Now both entities have A+B').toEqual([entity1, entity2]); + + // Queries update if components change + entity2.removeTag('B'); + expect(queryAB.getEntities(), 'Component force removed from entity, only entity1 A+B').toEqual([entity1]); + + // Queries are deferred by default, so queries will update after removals + entity1.removeTag('B'); + expect(queryAB.getEntities(), 'No entities should match').toEqual([]); + }); + it('can add entities to queries', () => { const world = new ex.World(null); const entity1 = new ex.Entity(); @@ -111,6 +144,97 @@ describe('A QueryManager', () => { expect(queryAB.getEntities()).toEqual([]); }); + it('can remove entities from queries that have components and not(tags)', () => { + const world = new ex.World(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponentA()); + entity1.addComponent(new FakeComponentB()); + entity1.addTag('ex.offscreen'); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponentA()); + entity2.addComponent(new FakeComponentB()); + + const queryAB = world.query({ + components: { + all: [FakeComponentA, FakeComponentB] + }, + tags: { + not: ['ex.offscreen'] + } + }); + world.queryManager.addEntity(entity1); + world.queryManager.addEntity(entity2); + expect(queryAB.getEntities()).toEqual([entity2]); + + entity2.addTag('ex.offscreen'); + world.queryManager.addEntity(entity2); + expect(queryAB.getEntities()).toEqual([]); + + entity1.removeTag('ex.offscreen'); + world.queryManager.addEntity(entity1); + expect(queryAB.getEntities()).toEqual([entity1]); + }); + + it('can remove entities from queries that have components and not(components)', () => { + const world = new ex.World(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponentA()); + entity1.addComponent(new FakeComponentB()); + entity1.addComponent(new FakeComponentC()); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponentA()); + entity2.addComponent(new FakeComponentB()); + + const queryAB = world.query({ + components: { + all: [FakeComponentA, FakeComponentB], + not: [FakeComponentC] + } + }); + world.queryManager.addEntity(entity1); + world.queryManager.addEntity(entity2); + expect(queryAB.getEntities()).toEqual([entity2]); + + entity2.addComponent(new FakeComponentC()); + world.queryManager.addEntity(entity2); + expect(queryAB.getEntities()).toEqual([]); + + entity1.removeComponent(FakeComponentC, true); + world.queryManager.addEntity(entity1); + expect(queryAB.getEntities()).toEqual([entity1]); + }); + + it('can add entities queries that have 1 and(component) + or(components)', () => { + const world = new ex.World(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponentA()); + entity1.addComponent(new FakeComponentC()); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponentA()); + entity2.addComponent(new FakeComponentB()); + + const queryAB = world.query({ + components: { + all: [FakeComponentA], + any: [FakeComponentB, FakeComponentC] + } + }); + world.queryManager.addEntity(entity1); + world.queryManager.addEntity(entity2); + expect(queryAB.getEntities()).toEqual([entity1, entity2]); + + entity1.removeComponent(FakeComponentC, true); + world.queryManager.addEntity(entity1); + expect(queryAB.getEntities()).toEqual([entity2]); + + entity2.removeComponent(FakeComponentB, true); + world.queryManager.addEntity(entity2); + expect(queryAB.getEntities()).toEqual([]); + }); + it('can remove entities from tag queries', () => { const world = new ex.World(null); const entity1 = new ex.Entity(); @@ -150,7 +274,7 @@ describe('A QueryManager', () => { expect(queryAB.getEntities()).toEqual([entity1, entity2]); const removed = entity1.get(FakeComponentA); - entity1.removeComponent(FakeComponentA); + entity1.removeComponent(FakeComponentA, true); world.queryManager.removeComponent(entity1, removed); expect(queryAB.getEntities()).toEqual([entity2]); @@ -170,12 +294,16 @@ describe('A QueryManager', () => { world.queryManager.addEntity(entity1); world.queryManager.addEntity(entity2); - expect(queryAB.getEntities()).toEqual([entity1, entity2]); + expect(queryAB.entities).toEqual([entity1, entity2]); entity1.removeTag('A'); world.queryManager.removeTag(entity1, 'A'); - expect(queryAB.getEntities()).toEqual([entity2]); + expect(queryAB.entities).toEqual([entity2]); + + entity2.removeTag('B'); + world.queryManager.removeTag(entity2, 'B'); + expect(queryAB.entities).toEqual([]); }); it("removing components unrelated to the query doesn't remove the entity", () => { diff --git a/src/spec/vitest/QuerySpec.ts b/src/spec/vitest/QuerySpec.ts index 918e896f..a6ba9481 100644 --- a/src/spec/vitest/QuerySpec.ts +++ b/src/spec/vitest/QuerySpec.ts @@ -43,10 +43,10 @@ describe('A query', () => { const entity2 = new ex.Entity(); entity2.addComponent(compA); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); expect(queryAB.getEntities()).toEqual([entity1]); - queryAB.checkAndAdd(entity2); + queryAB.checkAndModify(entity2); expect(queryAB.getEntities()).toEqual([entity1]); }); @@ -64,10 +64,10 @@ describe('A query', () => { const entity2 = new ex.Entity(); entity2.addComponent(compB); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); expect(queryAB.getEntities()).toEqual([entity1]); - queryAB.checkAndAdd(entity2); + queryAB.checkAndModify(entity2); expect(queryAB.getEntities()).toEqual([entity1, entity2]); }); @@ -87,10 +87,10 @@ describe('A query', () => { entity2.addComponent(compB); entity2.addTag('tag'); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); expect(queryAB.getEntities()).toEqual([entity1]); - queryAB.checkAndAdd(entity2); + queryAB.checkAndModify(entity2); expect(queryAB.getEntities()).toEqual([entity1, entity2]); }); @@ -110,10 +110,10 @@ describe('A query', () => { entity2.addComponent(compA); entity2.addComponent(compB); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); expect(queryAB.getEntities()).toEqual([entity1]); - queryAB.checkAndAdd(entity2); + queryAB.checkAndModify(entity2); expect(queryAB.getEntities()).toEqual([entity1]); }); @@ -134,10 +134,10 @@ describe('A query', () => { entity2.addComponent(compA); entity2.addTag('tag'); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); expect(queryAB.getEntities()).toEqual([entity1]); - queryAB.checkAndAdd(entity2); + queryAB.checkAndModify(entity2); expect(queryAB.getEntities()).toEqual([entity1]); }); @@ -152,7 +152,7 @@ describe('A query', () => { const entity2 = new ex.Entity(); entity2.addComponent(compA); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); expect(queryAB.getEntities()).toEqual([entity1]); queryAB.removeEntity(entity2); @@ -176,7 +176,7 @@ describe('A query', () => { done(); }); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); })); it('notifies observers of when something is added to the query', () => @@ -187,7 +187,7 @@ describe('A query', () => { const entity1 = new ex.Entity(); entity1.addComponent(compA); entity1.addComponent(compB); - queryAB.checkAndAdd(entity1); + queryAB.checkAndModify(entity1); queryAB.entityRemoved$.subscribe((e) => { expect(e).toBe(entity1);