From 7cea866467552dcb98e13ce37df6742c7326d316 Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Sat, 6 Sep 2025 19:30:43 +0300 Subject: [PATCH 1/4] Refactor `Query` to use archetypes and tables internally --- src/ecs/query.js | 212 +++++++++++++++++++++++++++++------------------ 1 file changed, 130 insertions(+), 82 deletions(-) diff --git a/src/ecs/query.js b/src/ecs/query.js index e7c55477..3e41f873 100644 --- a/src/ecs/query.js +++ b/src/ecs/query.js @@ -1,10 +1,10 @@ -/** @import { TableId } from './typedef/index.js'*/ +/** @import { TableId, TableRow } from './typedef/index.js'*/ /** @import { Constructor, TypeId } from '../reflect/index.js'*/ import { Entity } from './entities/index.js' import { World } from './registry.js' -import { Tables } from './tables/index.js' import { typeid } from '../reflect/index.js' +import { Table } from './tables/index.js' /** * Enables operations to be performed on specified set @@ -36,7 +36,7 @@ export class Query { * @private * @type {World} */ - registry + world /** * @readonly @@ -46,54 +46,37 @@ export class Query { /** * @private - * @type {any[][]} + * @type {TableId[]} */ - components = [] + tableIds = [] /** - * @private - * @type {Map} - */ - tablemapper = new Map() - - /** - * @param {World} registry + * @param {World} world * @param {[...TupleConstructor]} componentTypes */ - constructor(registry, componentTypes) { - this.registry = registry + constructor(world, componentTypes) { + this.world = world this.descriptors = componentTypes.map((c) => typeid(c)) - for (let i = 0; i < componentTypes.length; i++) { - this.components[i] = [] - } - - this.update(registry.getTables()) + this.update() } /** - * @param {Tables} table + * @returns {void} */ - update(table) { - const { descriptors, components } = this - const tableIds = table.getTableIds(descriptors, []) - - for (let i = 0; i < tableIds.length; i++) { - this.tablemapper.set(tableIds[i], i) - } + update() { + const { world } = this + const archetypes = world.getArchetypes() - // This will help implement query filters - // const archetypes = table.filterArchetypes((archetype)=>true) - for (let i = 0; i < descriptors.length; i++) { - for (let j = 0; j < tableIds.length; j++) { + const tableIds = filterMap(archetypes.values(), (archetype) => { + if (archetype.has(this.descriptors)) { + return archetype.tableId + } - // instead of keeping the component lists,keep the verified archetype - // as their ids to get them later - const bin = table.get(tableIds[j]).columns.get(descriptors[i]) + return undefined + }) - components[i].push(bin) - } - } + this.tableIds = tableIds } /** @@ -103,44 +86,46 @@ export class Query { * @returns {T | null} */ get(entity) { - const entities = this.registry.getEntities() + const { world, descriptors } = this + const entities = world.getEntities() const location = entities.get(entity.index) - if(!location) return null + if (!location) return null const { tableId, index } = location - const tableid = this.tablemapper.get( - tableId - ) + const table = world.getTables().get(tableId) - if (tableid === undefined) return null + if (table === undefined) return null + if (index > table.size() || index < 0) return null const components = new Array(this.descriptors.length) - for (let i = 0; i < this.descriptors.length; i++) { - components[i] = this.components[i][tableid][index] - } + // Safety: We check the bounds above + mapComponents(table, descriptors, index, components) // SAFETY: Components are fetched in same order and types as the generic. - return /** @type {T}*/(components) + return /** @type {T}*/ (components) } /** * @param {EachFunc} callback */ each(callback) { - const components = new Array(this.descriptors.length) + const { tableIds, descriptors } = this + const tables = this.world.getTables() - if (!this.components[0]) return + // SAFETY: Components are fetched below. + const components = /** @type {T}*/(new Array(this.descriptors.length)) - for (let j = 0; j < this.components[0].length; j++) { - for (let k = 0; k < this.components[0][j].length; k++) { - for (let l = 0; l < this.descriptors.length; l++) { - components[l] = this.components[l][j][k] - } + for (let i = 0; i < tableIds.length; i++) { + const table = tables.get(tableIds[i]) + + if (!table) continue - // @ts-ignore - // SAFETY: Components are type cast to the expected passed value + for (let row = 0; row < table.size(); row++) { + + // SAFETY: Row is in bounds + mapComponents(table, descriptors, row, components) callback(components) } } @@ -150,29 +135,38 @@ export class Query { * @param {EachCombinationFunc} callback */ eachCombination(callback) { - const components1 = new Array(this.descriptors.length) - const components2 = new Array(this.descriptors.length) + const { tableIds, descriptors } = this + const tables = this.world.getTables() + + // SAFETY: Components are filled below + const components1 = /** @type {T}*/(new Array(this.descriptors.length)) + const components2 = /** @type {T}*/(new Array(this.descriptors.length)) - if (!this.components[0]) return + for (let i = 0; i < tableIds.length; i++) { + const table1 = tables.get(tableIds[i]) - // This... many people will be very upset. - for (let j = 0; j < this.components[0].length; j++) { - for (let k = 0; k < this.components[0][j].length; k++) { - for (let l = j; l < this.components[0].length; l++) { - const nextup = l === j ? k + 1 : 0 + if (!table1) continue - for (let m = nextup; m < this.components[0][l].length; m++) { + for (let j = i; j < tableIds.length; j++) { + const table2 = tables.get(tableIds[i]) - for (let n = 0; n < this.descriptors.length; n++) { - components1[n] = this.components[n][j][k] - components2[n] = this.components[n][l][m] - } + if (!table2) continue - // @ts-ignore - // SAFETY: Components are type cast to the expected passed value + for (let row1 = 0; row1 < table1.size(); row1++) { + + // SAFETY: Row is in bounds + mapComponents(table1, descriptors, row1, components1) + + const nextup = i === j ? row1 + 1 : 0 + + for (let row2 = nextup; row2 < table2.size(); row2++) { + + // SAFETY: Row is in bounds + mapComponents(table2, descriptors, row2, components2) callback(components1, components2) } } + } } } @@ -181,17 +175,25 @@ export class Query { * @returns {T | null} */ single() { - const components = new Array(this.descriptors.length) + const { descriptors, world, tableIds } = this + const tables = world.getTables() - if (!this.components[0] || !this.components[0][0] || this.components[0][0][0] === undefined) return null + for (let i = 0; i < tableIds.length; i++) { + const table = tables.get(tableIds[i]) + + if (!table) continue + if (table.size() < 1) continue + + // SAFETY: Components are fetched below. + const components = /** @type {T}*/(new Array(this.descriptors.length)) + + // SAFETY: Table row is in bounds as checked above. + mapComponents(table, descriptors, 0, components) - for (let i = 0; i < this.descriptors.length; i++) { - components[i] = this.components[i][0][0] + return components } - // @ts-ignore - // SAFETY: Components are type cast to the expected return value - return components + return null } /** @@ -200,18 +202,64 @@ export class Query { * @returns {number} */ count() { + const { tableIds } = this + const tables = this.world.getTables() let sum = 0 - if (!this.components[0]) return 0 + for (let i = 0; i < tableIds.length; i++) { + const table = tables.get(tableIds[i]) - for (let i = 0; i < this.components[0].length; i++) { - sum += this.components[0][i].length - } + if (!table) continue + sum += table.size() + } + return sum } } +/** + * @template T + * @template U + * @param { readonly T[] } arr + * @param { (element:T, index:number) => U | undefined } callback + * @returns { U[] } + */ +function filterMap(arr, callback) { + const results = [] + + for (let i = 0; i < arr.length; i++) { + const result = callback(arr[i], i) + + if (result !== undefined) { + results.push(result) + } + } + + return results +} + +/** + * ### Safety + * The table row should be guaranteed to be in bounds by the caller. + * The table should also contain all the components described by the descriptor. + * + * @template {unknown[]} T + * @param {Table} table + * @param {TypeId[]} descriptor + * @param {number} row + * @param {[...T]} list + * + */ +function mapComponents(table, descriptor, row, list) { + for (let i = 0; i < descriptor.length; i++) { + const type = descriptor[i] + + // SAFETY:index guaranteed to be in bounds by the caller.The value should be there. + list[i] = /** @type {unknown}*/ (table.get(type, /** @type {TableRow} */ (row))) + } +} + /** * @template {unknown[]} T * @callback EachFunc From b4773116825bf7ae4191b3b3ae39983281f971a3 Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Sat, 6 Sep 2025 22:29:51 +0300 Subject: [PATCH 2/4] Add test for query --- src/ecs/tests/query.test.js | 196 ++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/ecs/tests/query.test.js diff --git a/src/ecs/tests/query.test.js b/src/ecs/tests/query.test.js new file mode 100644 index 00000000..a9aabfd9 --- /dev/null +++ b/src/ecs/tests/query.test.js @@ -0,0 +1,196 @@ +import { test, describe } from "node:test"; +import { Entity, Query, World } from "../index.js"; +import assert, { strictEqual } from "node:assert"; +import { typeid } from "../../reflect/index.js"; + +describe("Testing `Query`", () => { + test('query for single component, single entity', () => { + const world = createWorld() + const query = new Query(world, [A]) + const components = query.single() + + assert(components) + + const [componentA] = components + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA.constructor)), + typeid(A) + ) + }) + + test('query for multiple components, single entity', () => { + const world = createWorld() + const query = new Query(world, [A, B, C]) + const components = query.single() + + assert(components) + + const [componentA, componentB, componentC] = components + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA.constructor)), + typeid(A) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentB.constructor)), + typeid(B) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentC.constructor)), + typeid(C) + ) + }) + + test('query for single component, multiple entities', () => { + const world = createWorld() + const query = new Query(world, [A]) + + query.each(([component]) => { + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(component.constructor)), + typeid(A) + ) + }) + }) + + test('query for multiple components, multiple entities', () => { + const world = createWorld() + const query = new Query(world, [A, B, C]) + + query.each(([componentA, componentB, componentC]) => { + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA.constructor)), + typeid(A) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentB.constructor)), + typeid(B) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentC.constructor)), + typeid(C) + ) + }) + }) + + test('query for single component, specific entity', () => { + const world = createWorld() + const query = new Query(world, [A]) + const entity = new Entity(0) + const components = query.get(entity) + assert(components) + + const [componentA] = components + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA.constructor)), + typeid(A) + ) + }) + + test('query for multiple components, specific entity', () => { + const world = createWorld() + const query = new Query(world, [A, B, C]) + const entity = new Entity(0) + const components = query.get(entity) + + assert(components) + + const [componentA, componentB, componentC] = components + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA.constructor)), + typeid(A) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentB.constructor)), + typeid(B) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentC.constructor)), + typeid(C) + ) + }) + + test('query for combination of entities', () => { + const world = createWorld() + const query = new Query(world, [A, B, C]) + let count = 0 + + query.eachCombination(([componentA1, componentB1, componentC1],[componentA2, componentB2, componentC2]) => { + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA1.constructor)), + typeid(A) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentB1.constructor)), + typeid(B) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentC1.constructor)), + typeid(C) + ) + + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentA2.constructor)), + typeid(A) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentB2.constructor)), + typeid(B) + ) + strictEqual( + typeid(/**@type {import("../../reflect/index.js").Constructor}*/(componentC2.constructor)), + typeid(C) + ) + count += 1 + }) + + // nCr where n = 10 and r = 2, combinatorial + strictEqual(count,45) + }) + + test('query for count of entities+', () => { + const world = createWorld() + const query = new Query(world, [A]) + + strictEqual(query.count(), 30) + }) +}) + +class A { + /** + * @param {number} [id] + */ + constructor(id) { + this.id = id + } +} +class B { + /** + * @param {number} [id] + */ + constructor(id) { + this.id = id + } +} +class C { } + +function createWorld() { + const world = new World() + + for (let i = 20; i < 30; i++) { + world.spawn([new A(i), new B(i), new C()]) + } + for (let i = 10; i < 20; i++) { + world.spawn([new A(i), new B(i)]) + } + for (let i = 0; i < 10; i++) { + world.spawn([new A(i)]) + } + for (let i = 0; i < 10; i++) { + world.spawn([new B(i)]) + } + for (let i = 0; i < 10; i++) { + world.spawn([new C()]) + } + + return world +} \ No newline at end of file From 538db57150fb70fd1b74feba82cccd524a9c3f5d Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Sun, 7 Sep 2025 16:57:05 +0300 Subject: [PATCH 3/4] Move `Query` --- src/ecs/index.js | 2 +- src/ecs/query/index.js | 1 + src/ecs/{ => query}/query.js | 12 ++++++------ src/render-core/systems/bin.js | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 src/ecs/query/index.js rename src/ecs/{ => query}/query.js (95%) diff --git a/src/ecs/index.js b/src/ecs/index.js index 01ac9422..63ebbd77 100644 --- a/src/ecs/index.js +++ b/src/ecs/index.js @@ -1,5 +1,5 @@ export * from './archetype/index.js' -export * from './query.js' +export * from './query/index.js' export * from './registry.js' export * from './schedule/index.js' export * from './typestore.js' diff --git a/src/ecs/query/index.js b/src/ecs/query/index.js new file mode 100644 index 00000000..0c8a2e47 --- /dev/null +++ b/src/ecs/query/index.js @@ -0,0 +1 @@ +export * from './query.js' \ No newline at end of file diff --git a/src/ecs/query.js b/src/ecs/query/query.js similarity index 95% rename from src/ecs/query.js rename to src/ecs/query/query.js index 3e41f873..543383c0 100644 --- a/src/ecs/query.js +++ b/src/ecs/query/query.js @@ -1,10 +1,10 @@ -/** @import { TableId, TableRow } from './typedef/index.js'*/ -/** @import { Constructor, TypeId } from '../reflect/index.js'*/ +/** @import { TableId, TableRow } from '../typedef/index.js'*/ +/** @import { Constructor, TypeId } from '../../reflect/index.js'*/ -import { Entity } from './entities/index.js' -import { World } from './registry.js' -import { typeid } from '../reflect/index.js' -import { Table } from './tables/index.js' +import { Entity } from '../entities/index.js' +import { World } from '../registry.js' +import { typeid } from '../../reflect/index.js' +import { Table } from '../tables/index.js' /** * Enables operations to be performed on specified set diff --git a/src/render-core/systems/bin.js b/src/render-core/systems/bin.js index 24b4b4fb..af1d56c4 100644 --- a/src/render-core/systems/bin.js +++ b/src/render-core/systems/bin.js @@ -1,6 +1,6 @@ /** @import { SystemFunc } from '../../ecs/index.js' */ /** @import { Constructor } from '../../reflect/index.js' */ -import { Query } from '../../ecs/query.js' +import { Query } from '../../ecs/index.js' import { typeid } from '../../reflect/index.js' import { GlobalTransform2D, GlobalTransform3D } from '../../transform/index.js' import { Material } from '../assets/material.js' From 3fef1190122c58f7b04a79b75b104f318938f66d Mon Sep 17 00:00:00 2001 From: waynemwashuma <94756970+waynemwashuma@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:28:11 +0300 Subject: [PATCH 4/4] Fix `Query.get` Ensure that the entity is actually contained in the query. --- src/ecs/query/query.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ecs/query/query.js b/src/ecs/query/query.js index 543383c0..2d0a9b58 100644 --- a/src/ecs/query/query.js +++ b/src/ecs/query/query.js @@ -86,7 +86,7 @@ export class Query { * @returns {T | null} */ get(entity) { - const { world, descriptors } = this + const { world, descriptors, tableIds } = this const entities = world.getEntities() const location = entities.get(entity.index) @@ -96,6 +96,7 @@ export class Query { const table = world.getTables().get(tableId) if (table === undefined) return null + if (!tableIds.includes(tableId)) return null if (index > table.size() || index < 0) return null const components = new Array(this.descriptors.length)