diff --git a/packages/ecsify/package.json b/packages/ecsify/package.json index a236e386..38c3dbbf 100644 --- a/packages/ecsify/package.json +++ b/packages/ecsify/package.json @@ -1,6 +1,6 @@ { "name": "ecsify", - "version": "0.0.8", + "version": "0.0.10", "private": false, "description": "A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript.", "keywords": [], diff --git a/packages/ecsify/src/app/create-app.ts b/packages/ecsify/src/app/create-app.ts index a46451c1..a9908f6b 100644 --- a/packages/ecsify/src/app/create-app.ts +++ b/packages/ecsify/src/app/create-app.ts @@ -133,12 +133,12 @@ export function createApp< return this._componentRegistry.markChanged(eid, component); }, - queryEntities(filter, options) { - return this._queryRegistry.queryEntities(filter, options); + queryEntities(queryOrFilter, options) { + return this._queryRegistry.queryEntities(queryOrFilter, options); }, - queryComponents(components, filter) { - return this._queryRegistry.queryComponents(components, filter); + queryComponents(components, queryOrFilter) { + return this._queryRegistry.queryComponents(components, queryOrFilter); }, addSystem( diff --git a/packages/ecsify/src/app/types/app.ts b/packages/ecsify/src/app/types/app.ts index 147c1cf1..311966fc 100644 --- a/packages/ecsify/src/app/types/app.ts +++ b/packages/ecsify/src/app/types/app.ts @@ -10,6 +10,7 @@ import { TComponentDataTuple, TEntity, TExecuteQueryOptions, + TQuery, TQueryFilter, TQueryRegistry } from '../../query'; @@ -38,7 +39,7 @@ export type TApp = GAppContext['a r: GAppContext['resources']; /** - * Add a plugin to the app + * Add a plugin to the app. * @param plugin - The plugin to add * @returns The new app with the plugin added */ @@ -47,7 +48,7 @@ export type TApp = GAppContext['a ): TApp]>>; /** - * Add multiple plugins to the app + * Add multiple plugins to the app. * @param plugins - The plugins to add * @returns The new app with the plugins added */ @@ -144,7 +145,7 @@ export type TApp = GAppContext['a * ); * ``` */ - queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + queryEntities(queryOrFilter: TQueryFilter | TQuery, options?: TExecuteQueryOptions): TEntityId[]; /** * Queries components and returns matching entities with component data. @@ -165,7 +166,7 @@ export type TApp = GAppContext['a */ queryComponents( components: T, - filter?: TQueryFilter + queryOrFilter?: TQueryFilter | TQuery ): TComponentDataTuple[]; /** @@ -185,14 +186,14 @@ export type TApp = GAppContext['a ): void; /** - * Read all events of a specific type without consuming them + * Read all events of a specific type without consuming them. */ readEvent( type: GType ): TEvent[]; /** - * Read and consume all events of a specific type + * Read and consume all events of a specific type. */ consumeEvent( type: GType @@ -218,6 +219,8 @@ export type TAppContext = TInnerAppContext< TMergePlugins >; +export type TAppWithPlugins = TApp>; + export interface TInnerAppContext = {}> { components: GMergedPlugins extends { components: infer GComponents } ? GComponents extends Record diff --git a/packages/ecsify/src/query/categorize-evaluation-strategy.ts b/packages/ecsify/src/query/categorize-evaluation-strategy.ts index ce124c46..11e10c1e 100644 --- a/packages/ecsify/src/query/categorize-evaluation-strategy.ts +++ b/packages/ecsify/src/query/categorize-evaluation-strategy.ts @@ -1,4 +1,4 @@ -import { TQueryFilter } from './types'; +import { TQueryFilter } from './query-filters'; /** * Pre-categorizes a query's evaluation strategy. diff --git a/packages/ecsify/src/query/create-query-registry.test.ts b/packages/ecsify/src/query/create-query-registry.test.ts index 46d27b51..7b9ea59a 100644 --- a/packages/ecsify/src/query/create-query-registry.test.ts +++ b/packages/ecsify/src/query/create-query-registry.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createComponentRegistry, TComponentRegistry } from '../component'; import { createEntityIndex, TEntityIndex } from '../entity'; import { createQueryRegistry, TQueryRegistry } from './create-query-registry'; -import { Added, And, Changed, Or, Removed, With, Without } from './query-filters'; +import { And, Or, With, Without } from './query-filters'; import { Entity } from './types'; describe('createQueryRegistry', () => { @@ -331,53 +331,6 @@ describe('createQueryRegistry', () => { }); }); - describe('generateQueryHash', () => { - it('should generate string hashes', () => { - const Position = { x: [] as number[], y: [] as number[] }; - - const hash = queryRegistry.generateQueryHash(With(Position)); - - expect(typeof hash).toBe('string'); - expect(hash.length).toBeGreaterThan(0); - }); - - it('should generate same hash for identical filters', () => { - const Position = { x: [] as number[], y: [] as number[] }; - - const hash1 = queryRegistry.generateQueryHash(With(Position)); - const hash2 = queryRegistry.generateQueryHash(With(Position)); - - expect(hash1).toBe(hash2); - }); - - it('should generate different hashes for different filters', () => { - const Position = { x: [] as number[], y: [] as number[] }; - const Health = [] as number[]; - - const withHash = queryRegistry.generateQueryHash(With(Position)); - const withoutHash = queryRegistry.generateQueryHash(Without(Position)); - const addedHash = queryRegistry.generateQueryHash(Added(Position)); - const changedHash = queryRegistry.generateQueryHash(Changed(Position)); - const removedHash = queryRegistry.generateQueryHash(Removed(Position)); - const healthHash = queryRegistry.generateQueryHash(With(Health)); - - const hashes = [withHash, withoutHash, addedHash, changedHash, removedHash, healthHash]; - const uniqueHashes = new Set(hashes); - expect(uniqueHashes.size).toBe(hashes.length); - }); - - it('should handle component order consistently', () => { - const Position = { x: [] as number[], y: [] as number[] }; - const Velocity = { x: [] as number[], y: [] as number[] }; - - // Order shouldn't matter due to sorting in And - const hash1 = queryRegistry.generateQueryHash(And(With(Position), With(Velocity))); - const hash2 = queryRegistry.generateQueryHash(And(With(Velocity), With(Position))); - - expect(hash1).toBe(hash2); - }); - }); - describe('checkEntity', () => { it('should return true when entity matches query', () => { const Position = { x: [] as number[], y: [] as number[] }; diff --git a/packages/ecsify/src/query/create-query-registry.ts b/packages/ecsify/src/query/create-query-registry.ts index f0bcbb4d..2893a75b 100644 --- a/packages/ecsify/src/query/create-query-registry.ts +++ b/packages/ecsify/src/query/create-query-registry.ts @@ -1,8 +1,8 @@ import { TComponentRef, TComponentRegistry } from '../component'; import { TEntityId, TEntityIndex } from '../entity'; -import { categorizeEvaluationStrategy } from './categorize-evaluation-strategy'; -import { And, With } from './query-filters'; -import { Entity, TEntity, TQueryComponentValue, TQueryData, TQueryFilter } from './types'; +import { createQuery, isQuery, TQuery } from './queries'; +import { And, TQueryFilter, With } from './query-filters'; +import { Entity, TEntity, TQueryComponentValue } from './types'; /** * Creates a new query registry @@ -16,59 +16,53 @@ export function createQueryRegistry( _componentRegistry: componentRegistry, _queryCache: new Map(), - queryEntities(filter, options = {}) { + queryEntities(filterOrQuery, options = {}) { const { cache = true, ...getQueryOptions } = options; - const queryData = this.getQuery(filter, getQueryOptions); + const query = isQuery(filterOrQuery) + ? filterOrQuery + : this.getQuery(filterOrQuery, getQueryOptions); // Return cached result if available and not dirty - if (!queryData.isDirty && cache) { - return queryData.cachedResult; + if (!query.isDirty && cache) { + return query.cachedResult; } // Exit if no entities exist if (this._entityIndex._aliveCount <= 0) { - queryData.cachedResult = []; - queryData.isDirty = false; + query.cachedResult = []; + query.isDirty = false; return []; } // Find matching entities - // Dense iteration with O(1) bitmask checks - simple and cache-friendly - // If this becomes slow: consider archetype system (group entities by component signature)? - // https://www.youtube.com/watch?v=71RSWVyOMEY - const matchingEntities: TEntityId[] = []; - for (let i = 0; i < this._entityIndex._aliveCount; i++) { - const eid = this._entityIndex._dense[i]; - if (eid != null && filter.evaluate(this, eid, queryData)) { - matchingEntities.push(eid); - } - } + const matchingEntities = query.query(this); // Cache results - queryData.cachedResult = matchingEntities; - queryData.isDirty = false; + query.cachedResult = matchingEntities; + query.isDirty = false; return matchingEntities; }, queryComponents( components: GComponents, - filter?: TQueryFilter + queryOrFilter?: TQueryFilter | TQuery ): TComponentDataTuple[] { // Query entities matching the provided filter, // or infer a filter from the given components if none is provided - const matchingEntities = filter - ? this.queryEntities(filter) - : this.queryEntities( - And( - ...components.reduce((acc, val) => { - if (val !== Entity) { - acc.push(With(val)); - } - return acc; - }, [] as TQueryFilter[]) - ) - ); + const matchingEntities = + queryOrFilter != null + ? this.queryEntities(queryOrFilter) + : this.queryEntities( + And( + ...components.reduce((acc, val) => { + if (val !== Entity) { + acc.push(With(val)); + } + return acc; + }, [] as TQueryFilter[]) + ) + ); // For each entity, check if it has all components and get their data const results: TComponentDataTuple[] = []; @@ -126,46 +120,38 @@ export function createQueryRegistry( }, getQuery(filter, options = {}) { - const { evaluationStrategy = categorizeEvaluationStrategy(filter) } = options; const hash = filter.getHash(this); // Return cached query if exists if (this._queryCache.has(hash)) { - return this._queryCache.get(hash) as TQueryData; + return this._queryCache.get(hash) as TQuery; } - // Create new query data - const queryData: TQueryData = { - hash, - filter, - evaluationStrategy, - cachedResult: [], - isDirty: true, - generations: [] - }; - - // Let filter register - if (filter.register != null) { - filter.register(this, queryData); - } + return this.registerQuery(filter, options); + }, - // Cache the query - this._queryCache.set(hash, queryData); + registerQuery(queryOrFilter, options = {}) { + const { evaluationStrategy } = options; - return queryData; - }, + // Create new query data + const query = isQuery(queryOrFilter) + ? queryOrFilter + : createQuery(this, queryOrFilter, { + evaluationStrategy, + register: false // Will be registered below + }); - registerQuery(filter) { - return this.getQuery(filter); - }, + // Let query register itself + query.register(this); + + // Cache the query + this._queryCache.set(query.hash, query); - generateQueryHash(filter) { - return filter.getHash(this); + return query; }, - checkEntity(queryData, eid) { - // Use the stored filter's evaluate method with the query data - return queryData.filter.evaluate(this, eid, queryData); + checkEntity(query, eid) { + return query.evaluate(this, eid); }, reset() { @@ -180,53 +166,48 @@ export interface TQueryRegistry { /** Reference to the component registry */ _componentRegistry: TComponentRegistry; /** Cache of compiled queries by hash */ - _queryCache: Map; + _queryCache: Map; /** * Queries entities that match the specified filter and returns only entity IDs. */ - queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + queryEntities(queryOrFilter: TQueryFilter | TQuery, options?: TExecuteQueryOptions): TEntityId[]; /** * Queries components and returns matching entities with component data. */ queryComponents( components: GComponents, - filter?: TQueryFilter + queryOrFilter?: TQueryFilter | TQuery ): TComponentDataTuple[]; /** - * Gets or creates a compiled query - */ - getQuery(filter: TQueryFilter, options?: TGetQueryOptions): TQueryData; - - /** - * Registers a query (alias for getOrCreateQuery) + * Gets or creates a compiled query. */ - registerQuery(filter: TQueryFilter): TQueryData; + getQuery(filter: TQueryFilter, options?: TRegisterQueryOptions): TQuery; /** - * Generates a hash for a query filter + * Registers a query. */ - generateQueryHash(filter: TQueryFilter): string; + registerQuery(queryOrFilter: TQueryFilter | TQuery, options?: TRegisterQueryOptions): TQuery; /** - * Checks if an entity matches a query + * Checks if an entity matches a query. */ - checkEntity(queryData: TQueryData, eid: TEntityId): boolean; + checkEntity(query: TQuery, eid: TEntityId): boolean; /** - * Resets the query registry to its initial state + * Resets the query registry to its initial state. */ reset(): void; } -export interface TGetQueryOptions { +export interface TRegisterQueryOptions { /** Evaluation strategy to use for the query */ evaluationStrategy?: 'bitmask' | 'individual'; } -export interface TExecuteQueryOptions extends TGetQueryOptions { +export interface TExecuteQueryOptions extends TRegisterQueryOptions { /** Whether to cache the query result */ cache?: boolean; } diff --git a/packages/ecsify/src/query/index.ts b/packages/ecsify/src/query/index.ts index 9874510e..0bd763f1 100644 --- a/packages/ecsify/src/query/index.ts +++ b/packages/ecsify/src/query/index.ts @@ -1,4 +1,5 @@ export * from './categorize-evaluation-strategy'; export * from './create-query-registry'; +export * from './queries'; export * from './query-filters'; export * from './types'; diff --git a/packages/ecsify/src/query/queries/create-query.test.ts b/packages/ecsify/src/query/queries/create-query.test.ts new file mode 100644 index 00000000..f8d15c80 --- /dev/null +++ b/packages/ecsify/src/query/queries/create-query.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createComponentRegistry, TComponentRegistry } from '../../component'; +import { createEntityIndex, TEntityIndex } from '../../entity'; +import { createQueryRegistry, TQueryRegistry } from '../create-query-registry'; +import { And, With, Without } from '../query-filters'; +import { createQuery } from './create-query'; + +describe('createQuery function', () => { + let entityIndex: TEntityIndex; + let componentRegistry: TComponentRegistry; + let queryRegistry: TQueryRegistry; + + beforeEach(() => { + entityIndex = createEntityIndex(); + componentRegistry = createComponentRegistry(); + queryRegistry = createQueryRegistry(entityIndex, componentRegistry); + }); + + it('should create query with correct initial state', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const filter = With(Position); + + const query = createQuery(queryRegistry, filter); + + expect(query.filter).toBe(filter); + expect(query.cachedResult).toEqual([]); + expect(query.isDirty).toBe(true); + expect(query.generations).toEqual([0]); + expect(typeof query.hash).toBe('string'); + expect(query.hash.length).toBeGreaterThan(0); + expect(query.evaluationStrategy).toBe('bitmask'); // Default for simple With + }); + + it('should create query with custom options', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const customHash = 'custom-hash-123'; + + const query = createQuery(queryRegistry, With(Position), { + evaluationStrategy: 'individual', + hash: customHash + }); + + expect(query.evaluationStrategy).toBe('individual'); + expect(query.hash).toBe(customHash); + }); + + describe('register', () => { + it('should call filter register method', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const filter = With(Position); + const filterRegisterSpy = vi.spyOn(filter, 'register'); + + const query = createQuery(queryRegistry, filter); + + expect(filterRegisterSpy).toHaveBeenCalledWith(queryRegistry, query, undefined); + }); + }); + + describe('query', () => { + it('should return matching entities', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const query = createQuery(queryRegistry, With(Position)); + + // Create entities + const eid1 = entityIndex.createEntity(); + const eid2 = entityIndex.createEntity(); + + // Only eid1 has Position + componentRegistry.addComponent(eid1, Position, { x: 10, y: 20 }); + + const result = query.query(queryRegistry); + + expect(result).toEqual([eid1]); + }); + + it('should return empty array when no entities match', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const query = createQuery(queryRegistry, With(Position)); + + // Create entity without Position component + entityIndex.createEntity(); + + const result = query.query(queryRegistry); + + expect(result).toEqual([]); + }); + + it('should handle more complex filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const query = createQuery(queryRegistry, And(With(Position), Without(Health))); + + // Create entities + const eid1 = entityIndex.createEntity(); + const eid2 = entityIndex.createEntity(); + const eid3 = entityIndex.createEntity(); + + // eid1: Position only (should match) + componentRegistry.addComponent(eid1, Position, { x: 10, y: 20 }); + + // eid2: Position + Health (should not match) + componentRegistry.addComponent(eid2, Position, { x: 30, y: 40 }); + componentRegistry.addComponent(eid2, Health, 100); + + // eid3: Nothing (should not match) + + const result = query.query(queryRegistry); + + expect(result).toEqual([eid1]); + }); + }); + + describe('evaluate', () => { + it('should return true for matching entity', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const query = createQuery(queryRegistry, With(Position)); + + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + const result = query.evaluate(queryRegistry, eid); + + expect(result).toBe(true); + }); + + it('should return false for non-matching entity', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const query = createQuery(queryRegistry, With(Position)); + + const eid = entityIndex.createEntity(); + // No Position component added + + const result = query.evaluate(queryRegistry, eid); + + expect(result).toBe(false); + }); + }); + + describe('getHash', () => { + it('should return filter hash', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const filter = With(Position); + + const query = createQuery(queryRegistry, filter); + + const hash = query.getHash(queryRegistry); + + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); + expect(hash).toBe(filter.getHash(queryRegistry)); + }); + }); + + describe('markDirty', () => { + it('should set isDirty to true', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const query = createQuery(queryRegistry, With(Position)); + query.isDirty = false; // Reset to test + + query.markDirty(); + + expect(query.isDirty).toBe(true); + }); + + it('should work when already dirty', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const query = createQuery(queryRegistry, With(Position)); + + expect(query.isDirty).toBe(true); // Starts dirty + + query.markDirty(); + + expect(query.isDirty).toBe(true); + }); + }); +}); diff --git a/packages/ecsify/src/query/queries/create-query.ts b/packages/ecsify/src/query/queries/create-query.ts new file mode 100644 index 00000000..c38ef7a2 --- /dev/null +++ b/packages/ecsify/src/query/queries/create-query.ts @@ -0,0 +1,86 @@ +import { TEntityId } from '../../entity'; +import { categorizeEvaluationStrategy } from '../categorize-evaluation-strategy'; +import { TQueryRegistry } from '../create-query-registry'; +import { TQueryFilterParentType } from '../query-filters'; +import { TQueryData } from '../types'; + +export function createQuery( + queryRegistry: TQueryRegistry, + filter: TQueryData['filter'], + options: TCreateQueryOptions = {} +): TQuery { + const { + evaluationStrategy = categorizeEvaluationStrategy(filter), + hash = filter.getHash(queryRegistry), + register = true + } = options; + + const query: TQuery = { + filter, + evaluationStrategy, + hash, + cachedResult: [], + isDirty: true, + generations: [], + + register(queryRegistry, parentType) { + this.filter.register?.(queryRegistry, this, parentType); + }, + query(queryRegistry) { + // Find matching entities + // Dense iteration with O(1) bitmask checks - simple and cache-friendly + // If this becomes slow: consider archetype system (group entities by component signature)? + // https://www.youtube.com/watch?v=71RSWVyOMEY + const matchingEntities: TEntityId[] = []; + for (let i = 0; i < queryRegistry._entityIndex._aliveCount; i++) { + const eid = queryRegistry._entityIndex._dense[i]; + if (eid != null && this.filter.evaluate(queryRegistry, eid, this)) { + matchingEntities.push(eid); + } + } + + return matchingEntities; + }, + evaluate(queryRegistry, eid) { + return this.filter.evaluate(queryRegistry, eid, this); + }, + getHash(queryRegistry) { + return this.filter.getHash(queryRegistry); + }, + markDirty() { + this.isDirty = true; + } + }; + + // Register query if requested + if (register) { + queryRegistry.registerQuery(query); + } + + return query; +} + +export interface TCreateQueryOptions { + evaluationStrategy?: TQueryData['evaluationStrategy']; + hash?: TQueryData['hash']; + register?: boolean; +} + +export interface TQuery extends TQueryData { + register(queryRegistry: TQueryRegistry, parentType?: TQueryFilterParentType): void; + query(queryRegistry: TQueryRegistry): TEntityId[]; + evaluate(queryRegistry: TQueryRegistry, eid: TEntityId): boolean; + getHash(queryRegistry: TQueryRegistry): string; + markDirty(): void; +} + +export function isQuery(value: unknown): value is TQuery { + return ( + typeof value === 'object' && + value != null && + 'filter' in value && + typeof value.filter === 'object' && + 'hash' in value && + typeof value.hash === 'string' + ); +} diff --git a/packages/ecsify/src/query/queries/create-queue-query.test.ts b/packages/ecsify/src/query/queries/create-queue-query.test.ts new file mode 100644 index 00000000..6e2611c1 --- /dev/null +++ b/packages/ecsify/src/query/queries/create-queue-query.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createComponentRegistry, TComponentRegistry } from '../../component'; +import { createEntityIndex, TEntityIndex } from '../../entity'; +import { createQueryRegistry, TQueryRegistry } from '../create-query-registry'; +import { Changed, With } from '../query-filters'; +import { createQueueQuery } from './create-queue-query'; + +describe('createQueueQuery function', () => { + let entityIndex: TEntityIndex; + let componentRegistry: TComponentRegistry; + let queryRegistry: TQueryRegistry; + + beforeEach(() => { + entityIndex = createEntityIndex(); + componentRegistry = createComponentRegistry(); + queryRegistry = createQueryRegistry(entityIndex, componentRegistry); + }); + + it('should create queue query with correct initial state', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position), { maxQueueSize: 5 }); + + expect(queueQuery._entityQueue).toEqual([]); + expect(queueQuery.maxQueueSize).toBe(5); + expect(typeof queueQuery.query).toBe('function'); + }); + + describe('register', () => { + it('should register and setup auto-queuing on dirty', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + queueQuery.register(queryRegistry); + + // Initially empty queue + expect(queueQuery._entityQueue).toHaveLength(0); + + // Add component - should trigger auto-queue via dirty callback + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + // Should have queued result + expect(queueQuery._entityQueue).toHaveLength(1); + expect(queueQuery._entityQueue[0]?.entities).toEqual([eid]); + }); + + it('should work with Changed filter', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, Changed(Position)); + queueQuery.register(queryRegistry); + + // Create entity with component first + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + // Clear any initial queue from adding + queueQuery._entityQueue = []; + + // Change component - should trigger auto-queue + componentRegistry.updateComponent(eid, Position, { x: 15, y: 25 }); + + // Should have queued the changed entity + expect(queueQuery._entityQueue).toHaveLength(1); + expect(queueQuery._entityQueue[0]?.entities).toEqual([eid]); + }); + + it('should queue multiple changes separately', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + queueQuery.register(queryRegistry); + + // Add entities one by one + const eid1 = entityIndex.createEntity(); + componentRegistry.addComponent(eid1, Position, { x: 10, y: 20 }); + + const eid2 = entityIndex.createEntity(); + componentRegistry.addComponent(eid2, Position, { x: 30, y: 40 }); + + // Should have queued both changes + expect(queueQuery._entityQueue).toHaveLength(2); + expect(queueQuery._entityQueue[0]?.entities).toEqual([eid1]); + expect(queueQuery._entityQueue[1]?.entities).toEqual([eid1, eid2]); + }); + }); + + describe('query', () => { + it('should pop from queue when available', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + queueQuery.register(queryRegistry); + + // Add entity to trigger queuing + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + // Verify queue has content + expect(queueQuery._entityQueue).toHaveLength(1); + + // Query should pop from queue + const result = queueQuery.query(queryRegistry); + + expect(result).toEqual([eid]); + expect(queueQuery._entityQueue).toHaveLength(0); // Should be popped + }); + + it('should return FIFO order from queue', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + queueQuery.register(queryRegistry); + + // Add entities to create queue + const eid1 = entityIndex.createEntity(); + componentRegistry.addComponent(eid1, Position, { x: 10, y: 20 }); + + const eid2 = entityIndex.createEntity(); + componentRegistry.addComponent(eid2, Position, { x: 30, y: 40 }); + + // Should get first queued result + const result1 = queueQuery.query(queryRegistry); + expect(result1).toEqual([eid1]); + + // Should get second queued result + const result2 = queueQuery.query(queryRegistry); + expect(result2).toEqual([eid1, eid2]); + }); + + it('should fallback to direct query when queue is empty', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + + // Create entity but don't register (no auto-queuing) + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + // Query with empty queue should fallback to direct query + const result = queueQuery.query(queryRegistry); + expect(result).toEqual([eid]); + }); + + it('should handle empty results', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + + // Query with no entities and empty queue + const result = queueQuery.query(queryRegistry); + + expect(result).toEqual([]); + }); + + it('should respect maxQueueSize limit', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position), { maxQueueSize: 2 }); + queueQuery.register(queryRegistry); + + // Add more entities than queue size + const eid1 = entityIndex.createEntity(); + const eid2 = entityIndex.createEntity(); + const eid3 = entityIndex.createEntity(); + + componentRegistry.addComponent(eid1, Position, { x: 10, y: 20 }); + componentRegistry.addComponent(eid2, Position, { x: 30, y: 40 }); + componentRegistry.addComponent(eid3, Position, { x: 50, y: 60 }); + + // Should only keep maxQueueSize entries + expect(queueQuery._entityQueue).toHaveLength(2); + }); + }); + + describe('cleanup', () => { + it('should clear queue and call parent cleanup', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queueQuery = createQueueQuery(queryRegistry, With(Position)); + queueQuery.register(queryRegistry); + + // Add entity to create queue + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + expect(queueQuery._entityQueue).toHaveLength(1); + + // Cleanup should clear queue + queueQuery.cleanup(); + + expect(queueQuery._entityQueue).toHaveLength(0); + }); + }); +}); diff --git a/packages/ecsify/src/query/queries/create-queue-query.ts b/packages/ecsify/src/query/queries/create-queue-query.ts new file mode 100644 index 00000000..d5825cc6 --- /dev/null +++ b/packages/ecsify/src/query/queries/create-queue-query.ts @@ -0,0 +1,103 @@ +import { TEntityId } from '../../entity'; +import { TQueryRegistry } from '../create-query-registry'; +import { TQueryData } from '../types'; +import { + createReactiveQuery, + isReactiveQuery, + TCreateReactiveQueryOptions, + TReactiveQuery +} from './create-reactive-query'; + +export function createQueueQuery( + queryRegistry: TQueryRegistry, + filter: TQueryData['filter'], + options: TCreateQueueQueryOptions = {} +): TQueueQuery { + const { maxQueueSize = 5, ...reactiveQueryOptions } = options; + + const queueQuery: TQueueQuery = { + ...createReactiveQuery(queryRegistry, filter, reactiveQueryOptions), + _entityQueue: [], + maxQueueSize, + + register(queryRegistry, parentType) { + this.filter.register?.(queryRegistry, this, parentType); + + this.onDirty(() => { + // Query all matching entities + const matchingEntities: TEntityId[] = []; + for (let i = 0; i < queryRegistry._entityIndex._aliveCount; i++) { + const eid = queryRegistry._entityIndex._dense[i]; + if (eid != null && this.filter.evaluate(queryRegistry, eid, this)) { + matchingEntities.push(eid); + } + } + + // Push to queue + this._entityQueue.push({ + entities: matchingEntities, + timestamp: Date.now() + }); + + // Limit queue size + if (this._entityQueue.length > this.maxQueueSize) { + this._entityQueue.shift(); + } + }); + }, + + query(queryRegistry) { + // Pop from queue if available + const queued = this._entityQueue.shift(); + if (queued != null) { + return queued.entities; + } + + // Re-query all matching entities + const matchingEntities: TEntityId[] = []; + for (let i = 0; i < queryRegistry._entityIndex._aliveCount; i++) { + const eid = queryRegistry._entityIndex._dense[i]; + if (eid != null && this.filter.evaluate(queryRegistry, eid, this)) { + matchingEntities.push(eid); + } + } + + return matchingEntities; + }, + + cleanup() { + this._callbacks = []; + this._entityQueue = []; + } + }; + + return queueQuery; +} + +export interface TCreateQueueQueryOptions extends TCreateReactiveQueryOptions { + /** Maximum number of entity sets to keep in queue (default: 5) */ + maxQueueSize?: number; +} + +export interface TQueuedEntitySet { + entities: TEntityId[]; + timestamp: number; +} + +export interface TQueueQuery extends TReactiveQuery { + /** Queue of pre-computed entity sets */ + _entityQueue: TQueuedEntitySet[]; + /** Maximum number of entity sets to keep in queue */ + maxQueueSize: number; + + /** + * Query entities - pops from queue if available, otherwise queries directly + * @param queryRegistry - The query registry to use + * @returns Array of entity IDs that match the query + */ + query(queryRegistry: TQueryRegistry): TEntityId[]; +} + +export function isQueueQuery(value: unknown): value is TQueueQuery { + return isReactiveQuery(value) && '_entityQueue' in value && Array.isArray(value._entityQueue); +} diff --git a/packages/ecsify/src/query/queries/create-reactive-query.test.ts b/packages/ecsify/src/query/queries/create-reactive-query.test.ts new file mode 100644 index 00000000..a5554b23 --- /dev/null +++ b/packages/ecsify/src/query/queries/create-reactive-query.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createComponentRegistry, TComponentRegistry } from '../../component'; +import { createEntityIndex, TEntityIndex } from '../../entity'; +import { createQueryRegistry, TQueryRegistry } from '../create-query-registry'; +import { With } from '../query-filters'; +import { createReactiveQuery } from './create-reactive-query'; + +describe('createReactiveQuery function', () => { + let entityIndex: TEntityIndex; + let componentRegistry: TComponentRegistry; + let queryRegistry: TQueryRegistry; + + beforeEach(() => { + entityIndex = createEntityIndex(); + componentRegistry = createComponentRegistry(); + queryRegistry = createQueryRegistry(entityIndex, componentRegistry); + }); + + it('should create reactive query with correct initial state', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + + expect(reactiveQuery._callbacks).toEqual([]); + expect(typeof reactiveQuery.markDirty).toBe('function'); + expect(typeof reactiveQuery.onDirty).toBe('function'); + expect(typeof reactiveQuery.cleanup).toBe('function'); + expect(reactiveQuery.isDirty).toBe(true); // Base query starts dirty + }); + + describe('markDirty', () => { + it('should set isDirty to true', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + reactiveQuery.isDirty = false; // Reset to test + + reactiveQuery.markDirty(); + + expect(reactiveQuery.isDirty).toBe(true); + }); + + it('should call all registered callbacks', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + reactiveQuery.onDirty(callback1); + reactiveQuery.onDirty(callback2); + + reactiveQuery.markDirty(); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); + + describe('onDirty', () => { + it('should register callback and return unregister function', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + const callback = vi.fn(); + + const unregister = reactiveQuery.onDirty(callback); + + expect(reactiveQuery._callbacks).toContain(callback); + expect(typeof unregister).toBe('function'); + }); + + it('should allow multiple callbacks to be registered', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + reactiveQuery.onDirty(callback1); + reactiveQuery.onDirty(callback2); + + expect(reactiveQuery._callbacks).toHaveLength(2); + expect(reactiveQuery._callbacks).toContain(callback1); + expect(reactiveQuery._callbacks).toContain(callback2); + }); + + it('should return unregister function that removes callback', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + const callback = vi.fn(); + + const unregister = reactiveQuery.onDirty(callback); + expect(reactiveQuery._callbacks).toContain(callback); + + unregister(); + expect(reactiveQuery._callbacks).not.toContain(callback); + }); + + it('should work with real component changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + reactiveQuery.register(queryRegistry); // Register with component callbacks + + const dirtyCallback = vi.fn(); + reactiveQuery.onDirty(dirtyCallback); + + // Add component should trigger dirty + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position, { x: 10, y: 20 }); + + expect(dirtyCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('cleanup', () => { + it('should clear all callbacks', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const reactiveQuery = createReactiveQuery(queryRegistry, With(Position)); + + reactiveQuery.onDirty(vi.fn()); + reactiveQuery.onDirty(vi.fn()); + + expect(reactiveQuery._callbacks).toHaveLength(2); + + reactiveQuery.cleanup(); + + expect(reactiveQuery._callbacks).toHaveLength(0); + }); + }); +}); diff --git a/packages/ecsify/src/query/queries/create-reactive-query.ts b/packages/ecsify/src/query/queries/create-reactive-query.ts new file mode 100644 index 00000000..6119f88b --- /dev/null +++ b/packages/ecsify/src/query/queries/create-reactive-query.ts @@ -0,0 +1,55 @@ +import { TQueryRegistry } from '../create-query-registry'; +import { TQueryData } from '../types'; +import { createQuery, isQuery, TCreateQueryOptions, TQuery } from './create-query'; + +export function createReactiveQuery( + queryRegistry: TQueryRegistry, + filter: TQueryData['filter'], + options: TCreateReactiveQueryOptions = {} +): TReactiveQuery { + return { + ...createQuery(queryRegistry, filter, options), + _callbacks: [], + markDirty() { + this.isDirty = true; + for (const callback of this._callbacks) { + callback(); + } + }, + onDirty(callback) { + this._callbacks.push(callback); + return () => { + const index = this._callbacks.indexOf(callback); + if (index != null && index !== -1) { + this._callbacks.splice(index, 1); + } + }; + }, + cleanup() { + this._callbacks = []; + } + }; +} + +export interface TCreateReactiveQueryOptions extends TCreateQueryOptions {} + +export interface TReactiveQuery extends TQuery { + /** Registered callbacks that fire when query becomes dirty */ + _callbacks: (() => void)[]; + + /** + * Register callback that fires when query becomes dirty + * @param callback Function to call when query needs re-evaluation + * @returns Unregister function + */ + onDirty(callback: () => void): () => void; + + /** + * Clean up all registered callbacks + */ + cleanup(): void; +} + +export function isReactiveQuery(value: unknown): value is TReactiveQuery { + return isQuery(value) && '_callbacks' in value && Array.isArray(value._callbacks); +} diff --git a/packages/ecsify/src/query/queries/index.ts b/packages/ecsify/src/query/queries/index.ts new file mode 100644 index 00000000..cdd8371a --- /dev/null +++ b/packages/ecsify/src/query/queries/index.ts @@ -0,0 +1,3 @@ +export * from './create-query'; +export * from './create-queue-query'; +export * from './create-reactive-query'; diff --git a/packages/ecsify/src/query/query-filters.test.ts b/packages/ecsify/src/query/query-filters.test.ts index 59e91556..684e2037 100644 --- a/packages/ecsify/src/query/query-filters.test.ts +++ b/packages/ecsify/src/query/query-filters.test.ts @@ -333,6 +333,21 @@ describe('Query Filters', () => { ); expect(positionWithChangedHealth).toEqual([eid1]); }); + + it('should handle empty And filter', () => { + const result = queryRegistry.queryEntities(And()); + expect(result).toEqual([]); + }); + + it('should handle single filter in And', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position); + + const result = queryRegistry.queryEntities(And(With(Position))); + expect(result).toEqual([eid]); + }); }); describe('Or filter', () => { @@ -433,6 +448,21 @@ describe('Query Filters', () => { const result = queryRegistry.queryEntities(Or(With(Position), With(Health))); expect(result).toEqual([]); }); + + it('should handle empty Or filter', () => { + const result = queryRegistry.queryEntities(Or()); + expect(result).toEqual([]); + }); + + it('should handle single filter in Or', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = entityIndex.createEntity(); + componentRegistry.addComponent(eid, Position); + + const result = queryRegistry.queryEntities(Or(With(Position))); + expect(result).toEqual([eid]); + }); }); describe('Complex filter combinations', () => { @@ -558,51 +588,50 @@ describe('Query Filters', () => { }); }); - describe('Edge cases', () => { - it('should handle empty And filter', () => { - const result = queryRegistry.queryEntities(And()); - expect(result).toEqual([]); - }); + describe('getHash', () => { + it('should generate string hashes', () => { + const Position = { x: [] as number[], y: [] as number[] }; - it('should handle empty Or filter', () => { - const result = queryRegistry.queryEntities(Or()); - expect(result).toEqual([]); + const hash = With(Position).getHash(queryRegistry); + + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); }); - it('should handle single filter in And', () => { + it('should generate same hash for identical filters', () => { const Position = { x: [] as number[], y: [] as number[] }; - const eid = entityIndex.createEntity(); - componentRegistry.addComponent(eid, Position); + const hash1 = With(Position).getHash(queryRegistry); + const hash2 = With(Position).getHash(queryRegistry); - const result = queryRegistry.queryEntities(And(With(Position))); - expect(result).toEqual([eid]); + expect(hash1).toBe(hash2); }); - it('should handle single filter in Or', () => { + it('should generate different hashes for different filters', () => { const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; - const eid = entityIndex.createEntity(); - componentRegistry.addComponent(eid, Position); + const withHash = With(Position).getHash(queryRegistry); + const withoutHash = Without(Position).getHash(queryRegistry); + const addedHash = Added(Position).getHash(queryRegistry); + const changedHash = Changed(Position).getHash(queryRegistry); + const removedHash = Removed(Position).getHash(queryRegistry); + const healthHash = With(Health).getHash(queryRegistry); - const result = queryRegistry.queryEntities(Or(With(Position))); - expect(result).toEqual([eid]); + const hashes = [withHash, withoutHash, addedHash, changedHash, removedHash, healthHash]; + const uniqueHashes = new Set(hashes); + expect(uniqueHashes.size).toBe(hashes.length); }); - it('should handle queries with non-existent components', () => { + it('should handle component order consistently', () => { const Position = { x: [] as number[], y: [] as number[] }; - const NonExistent = { value: [] as number[] }; - - const eid = entityIndex.createEntity(); - componentRegistry.addComponent(eid, Position); + const Velocity = { x: [] as number[], y: [] as number[] }; - // With non-existent should return empty - const withNonExistent = queryRegistry.queryEntities(With(NonExistent)); - expect(withNonExistent).toEqual([]); + // Order shouldn't matter due to sorting in And + const hash1 = And(With(Position), With(Velocity)).getHash(queryRegistry); + const hash2 = And(With(Velocity), With(Position)).getHash(queryRegistry); - // Without non-existent should return all entities - const withoutNonExistent = queryRegistry.queryEntities(Without(NonExistent)); - expect(withoutNonExistent).toEqual([eid]); + expect(hash1).toBe(hash2); }); }); @@ -662,42 +691,41 @@ describe('Query Filters', () => { componentRegistry.addComponent(eid1, Position); componentRegistry.addComponent(eid1, Health); + const positionQuery = queryRegistry.registerQuery(With(Position)); + const healthQuery = queryRegistry.registerQuery(With(Health)); + const velocityQuery = queryRegistry.registerQuery(With(Velocity)); + // Execute different queries to populate cache - const positionQuery = queryRegistry.queryEntities(With(Position)); - const healthQuery = queryRegistry.queryEntities(With(Health)); - const velocityQuery = queryRegistry.queryEntities(With(Velocity)); + const positionQueryResult = queryRegistry.queryEntities(positionQuery); + const healthQueryResult = queryRegistry.queryEntities(healthQuery); + const velocityQueryResult = queryRegistry.queryEntities(velocityQuery); // Verify initial state - expect(positionQuery).toEqual([eid1]); - expect(healthQuery).toEqual([eid1]); - expect(velocityQuery).toEqual([]); - - // Get query data to check dirty flags - const positionQueryData = queryRegistry.registerQuery(With(Position)); - const healthQueryData = queryRegistry.registerQuery(With(Health)); - const velocityQueryData = queryRegistry.registerQuery(With(Velocity)); + expect(positionQueryResult).toEqual([eid1]); + expect(healthQueryResult).toEqual([eid1]); + expect(velocityQueryResult).toEqual([]); // Queries should not be dirty after execution - expect(positionQueryData.isDirty).toBe(false); - expect(healthQueryData.isDirty).toBe(false); - expect(velocityQueryData.isDirty).toBe(false); + expect(positionQuery.isDirty).toBe(false); + expect(healthQuery.isDirty).toBe(false); + expect(velocityQuery.isDirty).toBe(false); // Add Velocity to eid2 - should ONLY affect Velocity query componentRegistry.addComponent(eid2, Velocity); // Only Velocity query should be marked as dirty - expect(positionQueryData.isDirty).toBe(false); // Should NOT be dirty - expect(healthQueryData.isDirty).toBe(false); // Should NOT be dirty - expect(velocityQueryData.isDirty).toBe(true); // Should be dirty + expect(positionQuery.isDirty).toBe(false); // Should NOT be dirty + expect(healthQuery.isDirty).toBe(false); // Should NOT be dirty + expect(velocityQuery.isDirty).toBe(true); // Should be dirty // Execute queries to verify results - const newPositionQuery = queryRegistry.queryEntities(With(Position)); - const newHealthQuery = queryRegistry.queryEntities(With(Health)); - const newVelocityQuery = queryRegistry.queryEntities(With(Velocity)); + const newPositionQueryResult = queryRegistry.queryEntities(With(Position)); + const newHealthQueryResult = queryRegistry.queryEntities(With(Health)); + const newVelocityQueryResult = queryRegistry.queryEntities(With(Velocity)); - expect(newPositionQuery).toEqual([eid1]); // No change - expect(newHealthQuery).toEqual([eid1]); // No change - expect(newVelocityQuery).toEqual([eid2]); // Changed + expect(newPositionQueryResult).toEqual([eid1]); // No change + expect(newHealthQueryResult).toEqual([eid1]); // No change + expect(newVelocityQueryResult).toEqual([eid2]); // Changed }); it('should invalidate multiple queries when shared component changes', () => { @@ -707,28 +735,32 @@ describe('Query Filters', () => { const eid1 = entityIndex.createEntity(); componentRegistry.addComponent(eid1, Health); - // Create multiple queries that depend on Position - const positionOnlyQuery = queryRegistry.queryEntities(With(Position)); - const positionAndHealthQuery = queryRegistry.queryEntities(And(With(Position), With(Health))); - const positionOrHealthQuery = queryRegistry.queryEntities(Or(With(Position), With(Health))); + const positionOnlyQuery = queryRegistry.registerQuery(With(Position)); + const positionAndHealthQuery = queryRegistry.registerQuery(And(With(Position), With(Health))); + const positionOrHealthQuery = queryRegistry.registerQuery(Or(With(Position), With(Health))); - // Get query data - const positionOnlyData = queryRegistry.registerQuery(With(Position)); - const positionAndHealthData = queryRegistry.registerQuery(And(With(Position), With(Health))); - const positionOrHealthData = queryRegistry.registerQuery(Or(With(Position), With(Health))); + // Execute different queries to populate cache + const positionOnlyQueryResult = queryRegistry.queryEntities(positionOnlyQuery); + const positionAndHealthQueryResult = queryRegistry.queryEntities(positionAndHealthQuery); + const positionOrHealthQueryResult = queryRegistry.queryEntities(positionOrHealthQuery); + + // Verify initial state + expect(positionOnlyQueryResult).toEqual([]); + expect(positionAndHealthQueryResult).toEqual([]); + expect(positionOrHealthQueryResult).toEqual([eid1]); // All should be clean after execution - expect(positionOnlyData.isDirty).toBe(false); - expect(positionAndHealthData.isDirty).toBe(false); - expect(positionOrHealthData.isDirty).toBe(false); + expect(positionOnlyQuery.isDirty).toBe(false); + expect(positionAndHealthQuery.isDirty).toBe(false); + expect(positionOrHealthQuery.isDirty).toBe(false); // Add Position component - should invalidate all Position-related queries componentRegistry.addComponent(eid1, Position); // All Position-related queries should be dirty - expect(positionOnlyData.isDirty).toBe(true); - expect(positionAndHealthData.isDirty).toBe(true); - expect(positionOrHealthData.isDirty).toBe(true); + expect(positionOnlyQuery.isDirty).toBe(true); + expect(positionAndHealthQuery.isDirty).toBe(true); + expect(positionOrHealthQuery.isDirty).toBe(true); }); }); @@ -737,16 +769,16 @@ describe('Query Filters', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const queryData = queryRegistry.registerQuery(And(With(Position), With(Health))); - expect(queryData.evaluationStrategy).toBe('bitmask'); + const query = queryRegistry.registerQuery(And(With(Position), With(Health))); + expect(query.evaluationStrategy).toBe('bitmask'); }); it('should use bitmask evaluation for simple Or filters', () => { const Position = { x: [] as number[], y: [] as number[] }; const Health = [] as number[]; - const queryData = queryRegistry.registerQuery(Or(With(Position), With(Health))); - expect(queryData.evaluationStrategy).toBe('bitmask'); + const query = queryRegistry.registerQuery(Or(With(Position), With(Health))); + expect(query.evaluationStrategy).toBe('bitmask'); }); it('should use individual evaluation for complex nested filters', () => { @@ -755,10 +787,10 @@ describe('Query Filters', () => { const Shield = [] as number[]; // Or(And(...), ...) should fall back to individual evaluation - const queryData = queryRegistry.registerQuery( + const query = queryRegistry.registerQuery( Or(And(With(Position), With(Health)), With(Shield)) ); - expect(queryData.evaluationStrategy).toBe('individual'); + expect(query.evaluationStrategy).toBe('individual'); }); it('should produce same results regardless of evaluation strategy', () => { diff --git a/packages/ecsify/src/query/query-filters.ts b/packages/ecsify/src/query/query-filters.ts index 1c348da0..eab82455 100644 --- a/packages/ecsify/src/query/query-filters.ts +++ b/packages/ecsify/src/query/query-filters.ts @@ -1,6 +1,11 @@ import { TComponentRef } from '../component'; +import { TEntityId } from '../entity'; import { TQueryRegistry } from './create-query-registry'; -import { TQueryData, TQueryFilter, TQueryParentType } from './types'; +import { TQuery } from './queries'; + +// ============================================================================= +// Query Filters +// ============================================================================= /** * Requires entity to have component @@ -23,17 +28,17 @@ export function With(component: T): TQueryFilter { return (entityMask & bitflag) !== 0; }, - register(queryRegistry, queryData, parentType): void { + register(queryRegistry, query, parentType): void { // Register callbacks to invalidate this query when components are added/removed queryRegistry._componentRegistry.onComponentAdd(component, () => { - queryData.isDirty = true; + query.markDirty(); }); queryRegistry._componentRegistry.onComponentRemove(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register the component mask in the appropriate structure - registerComponentMask(queryRegistry, queryData, component, 'with', parentType); + registerComponentMask(queryRegistry, query, component, 'with', parentType); }, getHash(queryRegistry): string { @@ -64,17 +69,17 @@ export function Without(component: T): TQueryFilter { return (entityMask & bitflag) === 0; }, - register(queryRegistry, queryData, parentType): void { + register(queryRegistry, query, parentType): void { // Register callbacks to invalidate this query when components are added/removed queryRegistry._componentRegistry.onComponentAdd(component, () => { - queryData.isDirty = true; + query.markDirty(); }); queryRegistry._componentRegistry.onComponentRemove(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register the component mask in the appropriate structure - registerComponentMask(queryRegistry, queryData, component, 'without', parentType); + registerComponentMask(queryRegistry, query, component, 'without', parentType); }, getHash(queryRegistry): string { @@ -96,19 +101,19 @@ export function Added(component: T): TQueryFilter { return queryRegistry._componentRegistry.wasAdded(eid, component); }, - register(queryRegistry, queryData, parentType): void { + register(queryRegistry, query, parentType): void { // Register callback to invalidate this query when components are added queryRegistry._componentRegistry.onComponentAdd(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register callback to invalidate when change tracking is flushed queryRegistry._componentRegistry.onComponentFlush(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register the component mask in the appropriate structure - registerComponentMask(queryRegistry, queryData, component, 'added', parentType); + registerComponentMask(queryRegistry, query, component, 'added', parentType); }, getHash(queryRegistry): string { @@ -130,19 +135,19 @@ export function Changed(component: T): TQueryFilter { return queryRegistry._componentRegistry.wasChanged(eid, component); }, - register(queryRegistry, queryData, parentType): void { + register(queryRegistry, query, parentType): void { // Register callback to invalidate this query when components are changed queryRegistry._componentRegistry.onComponentChange(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register callback to invalidate when change tracking is flushed queryRegistry._componentRegistry.onComponentFlush(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register the component mask in the appropriate structure - registerComponentMask(queryRegistry, queryData, component, 'changed', parentType); + registerComponentMask(queryRegistry, query, component, 'changed', parentType); }, getHash(queryRegistry): string { @@ -164,19 +169,19 @@ export function Removed(component: T): TQueryFilter { return queryRegistry._componentRegistry.wasRemoved(eid, component); }, - register(queryRegistry, queryData, parentType): void { + register(queryRegistry, query, parentType): void { // Register callback to invalidate this query when components are removed queryRegistry._componentRegistry.onComponentRemove(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register callback to invalidate when change tracking is flushed queryRegistry._componentRegistry.onComponentFlush(component, () => { - queryData.isDirty = true; + query.markDirty(); }); // Register the component mask in the appropriate structure - registerComponentMask(queryRegistry, queryData, component, 'removed', parentType); + registerComponentMask(queryRegistry, query, component, 'removed', parentType); }, getHash(queryRegistry): string { @@ -194,10 +199,10 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { type: 'And', filters, - evaluate(queryRegistry, eid, queryData): boolean { - switch (queryData.evaluationStrategy) { + evaluate(queryRegistry, eid, query): boolean { + switch (query.evaluationStrategy) { case 'bitmask': { - const { andMasks, orMasks, generations } = queryData; + const { andMasks, orMasks, generations } = query; const entityMasks = queryRegistry._componentRegistry._entityMasks; const addedMasks = queryRegistry._componentRegistry._addedMasks; const changedMasks = queryRegistry._componentRegistry._changedMasks; @@ -276,14 +281,14 @@ export function And(...filters: TQueryFilter[]): TQueryFilter { } case 'individual': - return filters.every((filter) => filter.evaluate(queryRegistry, eid, queryData)); + return filters.every((filter) => filter.evaluate(queryRegistry, eid, query)); } }, - register(queryRegistry, queryData): void { + register(queryRegistry, query): void { for (const filter of filters) { if (filter.register != null) { - filter.register(queryRegistry, queryData, 'And'); + filter.register(queryRegistry, query, 'And'); } } }, @@ -306,10 +311,10 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { type: 'Or', filters, - evaluate(queryRegistry, eid, queryData): boolean { - switch (queryData.evaluationStrategy) { + evaluate(queryRegistry, eid, query): boolean { + switch (query.evaluationStrategy) { case 'bitmask': { - const { orMasks, generations } = queryData; + const { orMasks, generations } = query; const entityMasks = queryRegistry._componentRegistry._entityMasks; const addedMasks = queryRegistry._componentRegistry._addedMasks; const changedMasks = queryRegistry._componentRegistry._changedMasks; @@ -353,14 +358,14 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { } case 'individual': - return filters.some((filter) => filter.evaluate(queryRegistry, eid, queryData)); + return filters.some((filter) => filter.evaluate(queryRegistry, eid, query)); } }, - register(queryRegistry, queryData): void { + register(queryRegistry, query): void { for (const filter of filters) { if (filter.register != null) { - filter.register(queryRegistry, queryData, 'Or'); + filter.register(queryRegistry, query, 'Or'); } } }, @@ -379,6 +384,10 @@ export function Or(...filters: TQueryFilter[]): TQueryFilter { export const All = And; export const Any = Or; +// ============================================================================= +// Helpers +// ============================================================================= + /** * Helper to get component ID, registering if needed */ @@ -395,10 +404,10 @@ function getComponentId(queryRegistry: TQueryRegistry, component: TComponentRef) */ function registerComponentMask( queryRegistry: TQueryRegistry, - queryData: TQueryData, + query: TQuery, component: TComponentRef, maskType: 'with' | 'without' | 'added' | 'changed' | 'removed', - parentType: TQueryParentType = 'And' + parentType: TQueryFilterParentType = 'And' ): void { const registry = queryRegistry._componentRegistry; const componentData = registry._componentMap.get(component); @@ -420,23 +429,49 @@ function registerComponentMask( } // Lazy allocation: only create objects when needed - if (queryData[targetMasks] == null) { - queryData[targetMasks] = {}; + if (query[targetMasks] == null) { + query[targetMasks] = {}; } - if (queryData[targetMasks]?.[generationId] == null) { - queryData[targetMasks]![generationId] = {}; + if (query[targetMasks]?.[generationId] == null) { + query[targetMasks]![generationId] = {}; } - if (queryData.affectedMasks == null) { - queryData.affectedMasks = {}; + if (query.affectedMasks == null) { + query.affectedMasks = {}; } // Add to appropriate mask - queryData[targetMasks]![generationId]![maskType] = - (queryData[targetMasks]?.[generationId]?.[maskType] ?? 0) | bitflag; - queryData.affectedMasks[generationId] = (queryData.affectedMasks[generationId] ?? 0) | bitflag; + query[targetMasks]![generationId]![maskType] = + (query[targetMasks]?.[generationId]?.[maskType] ?? 0) | bitflag; + query.affectedMasks[generationId] = (query.affectedMasks[generationId] ?? 0) | bitflag; // Add to generations array if not already present - if (!queryData.generations.includes(generationId)) { - queryData.generations.push(generationId); + if (!query.generations.includes(generationId)) { + query.generations.push(generationId); } } + +// ============================================================================= +// Types +// ============================================================================= + +export interface TBaseQueryFilter { + type: string; + evaluate(queryRegistry: TQueryRegistry, eid: TEntityId, query: TQuery): boolean; + register?( + queryRegistry: TQueryRegistry, + query: TQuery, + parentType?: TQueryFilterParentType + ): void; + getHash(queryRegistry: TQueryRegistry): string; +} + +export type TQueryFilter = + | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Added'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) + | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); + +export type TQueryFilterParentType = Extract; diff --git a/packages/ecsify/src/query/types.ts b/packages/ecsify/src/query/types.ts index 75e95342..1efad517 100644 --- a/packages/ecsify/src/query/types.ts +++ b/packages/ecsify/src/query/types.ts @@ -1,6 +1,6 @@ import { TComponentRef, TComponentValue } from '../component'; import { TEntityId } from '../entity'; -import { TQueryRegistry } from './create-query-registry'; +import { TQueryFilter } from './query-filters'; /** * Special entity symbol for component queries @@ -56,27 +56,5 @@ export interface TQueryData { affectedMasks?: Record; } -export interface TBaseQueryFilter { - type: string; - evaluate(queryRegistry: TQueryRegistry, eid: TEntityId, queryData: TQueryData): boolean; - register?( - queryRegistry: TQueryRegistry, - queryData: TQueryData, - parentType?: TQueryParentType - ): void; - getHash(queryRegistry: TQueryRegistry): string; -} - -export type TQueryParentType = Extract; - -export type TQueryFilter = - | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Added'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) - | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) - | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); - export type TQueryComponentValue = GComponent extends TEntity ? TEntityId : TComponentValue;