Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .config/tsc.lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"alwaysStrict": true,
"allowImportingTsExtensions": true,
"checkJs": true,
"module": "esnext",
"moduleResolution": "bundler",
Expand Down
1 change: 1 addition & 0 deletions .config/tsc.type.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"allowUnusedLabels": false,
"alwaysStrict": true,
"allowJs": true,
"allowImportingTsExtensions": true,
"checkJs": true,
"declaration": true,
"emitDeclarationOnly": true,
Expand Down
78 changes: 72 additions & 6 deletions packages/ecs/src/query/filters/filter.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,49 @@
/** @import { TypeId, Constructor } from '@wimaengine/type'*/
/** @import { FilterTransform, IdentityTransform, OptionalTransform } from './types' */
import { typeid } from '@wimaengine/type'

/**
* @interface
* @abstract
* @template {FilterTransform} [Transform = FilterTransform]
*/
export class QueryFilter {

/** @type {Transform} */
// @ts-ignore
typeTransform

constructor() {
if (new.target === QueryFilter) {
throw new TypeError('QueryFilter is abstract')
}
}

/**
* Returns whether a descriptor must be present for this filter to be
* applicable to an archetype.
*
* @param {TypeId} _type
* @returns {boolean}
*/
isRequired(_type) {
return true
}

/**
* @param {readonly TypeId[]} _types
* @throws {string} When the method is not implemented.
* @returns {boolean}
*/
archetype(_types) {
throw `Implement ${this.constructor.name}.archetype`
return true
}
}

/**
* @template T
* @implements {QueryFilter}
* @augments {QueryFilter<IdentityTransform>}
*/
export class Has {
export class Has extends QueryFilter {

/**
* @type {TypeId}
Expand All @@ -31,6 +54,7 @@ export class Has {
* @param {Constructor<T>} component
*/
constructor(component) {
super()
this.typeid = typeid(component)
}

Expand All @@ -52,11 +76,52 @@ export function has(component) {
return new Has(component)
}

/**
* Marks a component in a query as optional. Optional components do not affect
* archetype matching and are returned as `undefined` when absent.
*
* @template T
* @augments {QueryFilter<OptionalTransform<T>>}
*/
export class Optional extends QueryFilter {

/** @type {TypeId} */
typeid

/** @param {Constructor<T>} component */
constructor(component) {
super()
this.typeid = typeid(component)
}

/**
* @param {TypeId} type
* @returns {boolean}
*/
isRequired(type) {
return this.typeid !== type
}

/** @param {readonly TypeId[]} _types */
archetype(_types) {
return true
}
}

/**
* @template T
* @param {Constructor<T>} component
* @returns {Optional<T>}
*/
export function optional(component) {
return new Optional(component)
}

/**
* @template T
* @implements {QueryFilter}
* @augments {QueryFilter<IdentityTransform>}
*/
export class Without {
export class Without extends QueryFilter {

/**
* @type {TypeId}
Expand All @@ -67,6 +132,7 @@ export class Without {
* @param {Constructor<T>} component
*/
constructor(component) {
super()
this.typeid = typeid(component)
}

Expand Down
1 change: 1 addition & 0 deletions packages/ecs/src/query/filters/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './filter'
export * from './types.ts'
45 changes: 45 additions & 0 deletions packages/ecs/src/query/filters/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* A type-level function with a replaceable input slot.
*/
export interface FilterTransform {
_input: unknown
0: unknown
}

/** A transform that preserves the component tuple. */
export interface IdentityTransform extends FilterTransform {
0: this['_input']
}

/** Apply a transform to a concrete component tuple. */
export type ApplyTransform<Transform extends FilterTransform, Input> =
(Transform & { _input: Input })[0]

/** Transform the tuple entries matching a component into optional entries. */
export type Optionalize<Components extends unknown[], Component> = {
[K in keyof Components]: Extract<Component, Components[K]> extends never
? Components[K]
: Components[K] | undefined
}

export interface OptionalTransform<Component> extends FilterTransform {
0: this['_input'] extends unknown[]
? Optionalize<this['_input'], Component>
: never
}

/** Apply one filter's type-level transformation. */
export type ApplyFilter<Components extends unknown[], Filter> =
Filter extends { readonly typeTransform: infer Transform }
? Transform extends FilterTransform
? ApplyTransform<Transform, Components>
: Components
: Components

/** Apply filters from left to right. */
export type ApplyFilters<Components extends unknown[], Filters extends unknown[]> =
Filters extends [infer Filter, ...infer Rest]
? Rest extends unknown[]
? ApplyFilters<ApplyFilter<Components, Filter>, Rest>
: ApplyFilter<Components, Filter>
: Components
38 changes: 27 additions & 11 deletions packages/ecs/src/query/query.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @import { TableId, TableRow } from '../typedef'*/
/** @import { TypeId, TupleConstructor } from '@wimaengine/type'*/
/** @import { ApplyFilters } from './filters' */

import { typeid } from '@wimaengine/type'
import { Archetype } from '../archetype'
Expand All @@ -8,6 +9,12 @@ import { Table } from '../tables'
import { World } from '../world'
import { QueryFilter } from './filters'

/**
* @template {unknown[]} T
* @template {QueryFilter[]} U
* @typedef {ApplyFilters<T, U>} QueryComponents
*/

/**
* Enables operations to be performed on entities matching the
* given component constructors on a {@link World}.
Expand Down Expand Up @@ -43,6 +50,12 @@ export class Query {
*/
descriptors = []

/**
* @readonly
* @type {TypeId[]}
*/
requiredDescriptors = []

/**
* @readonly
* @type {U}
Expand Down Expand Up @@ -70,6 +83,9 @@ export class Query {
this.world = world
this.descriptors = componentTypes.map((c) => typeid(c))
this.filters = filters
this.requiredDescriptors = this.descriptors.filter((descriptor) => {
return this.filters.every((entry) => entry.isRequired(descriptor))
})
this.update()
}

Expand All @@ -81,7 +97,7 @@ export class Query {
const archetypes = world.getArchetypes()

const tableIds = filterMap(archetypes.values(), (archetype) => {
if (!archetype.has(this.descriptors)) {
if (!archetype.has(this.requiredDescriptors)) {
return undefined
}
if (!filter(archetype, this.filters)) {
Expand All @@ -98,7 +114,7 @@ export class Query {
* Gets the components of a given entity.
*
* @param {EntityHandle} entity
* @returns {T | null}
* @returns {QueryComponents<T,U> | null}
*/
get(entity) {
const { world, descriptors, tableIds } = this
Expand All @@ -120,18 +136,18 @@ export class Query {
mapComponents(table, descriptors, index, components)

// SAFETY: Components are fetched in same order and types as the generic.
return /** @type {T}*/ (components)
return /** @type {QueryComponents<T,U>}*/ (components)
}

/**
* @param {EachFunc<T>} callback
* @param {EachFunc<QueryComponents<T,U>>} callback
*/
each(callback) {
const { tableIds, descriptors } = this
const tables = this.world.getTables()

// SAFETY: Components are fetched below.
const components = /** @type {T}*/(new Array(this.descriptors.length))
const components = /** @type {[...QueryComponents<T,U>]}*/(new Array(this.descriptors.length))

for (let i = 0; i < tableIds.length; i++) {
const table = tables.get(tableIds[i])
Expand All @@ -148,15 +164,15 @@ export class Query {
}

/**
* @param {EachCombinationFunc<T>} callback
* @param {EachCombinationFunc<QueryComponents<T,U>>} callback
*/
eachCombination(callback) {
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))
const components1 = /** @type {[...QueryComponents<T,U>]}*/(new Array(this.descriptors.length))
const components2 = /** @type {[...QueryComponents<T,U>]}*/(new Array(this.descriptors.length))

for (let i = 0; i < tableIds.length; i++) {
const table1 = tables.get(tableIds[i])
Expand Down Expand Up @@ -188,7 +204,7 @@ export class Query {
}

/**
* @returns {T | null}
* @returns {QueryComponents<T,U> | null}
*/
single() {
const { descriptors, world, tableIds } = this
Expand All @@ -201,7 +217,7 @@ export class Query {
if (table.size() < 1) continue

// SAFETY: Components are fetched below.
const components = /** @type {T}*/(new Array(this.descriptors.length))
const components = /** @type {[...QueryComponents<T,U>]}*/(new Array(this.descriptors.length))

// SAFETY: Table row is in bounds as checked above.
mapComponents(table, descriptors, 0, components)
Expand Down Expand Up @@ -282,7 +298,7 @@ function mapComponents(table, descriptor, row, list) {
*/
function filter(archetype, filters) {
for (let i = 0; i < filters.length; i++) {
if (!filters[i].archetype(archetype.types)) {
if (!filters[i]?.archetype(archetype.types)) {
return false
}
}
Expand Down
26 changes: 24 additions & 2 deletions packages/ecs/tests/query.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, describe } from "vitest";
import { EntityHandle, Query, has, without, World } from "..";
import { EntityHandle, Query, has, optional, without, World } from "..";
import assert, { strictEqual } from "node:assert";
import { typeid } from "@wimaengine/type";

Expand Down Expand Up @@ -123,6 +123,28 @@ describe("Testing `Query`", () => {
strictEqual(query.count(), 10)
})

test('query with an optional component.', () => {
const world = createWorld()
const query = new Query(world, [A, B], [optional(B)])
let missing = 0
let present = 0

query.each(([componentA, componentB]) => {
strictEqual(componentA instanceof A, true)

if (componentB) {
strictEqual(componentB instanceof B, true)
present += 1
} else {
missing += 1
}
})

strictEqual(missing, 10)
strictEqual(present, 20)
strictEqual(query.count(), 30)
})

test('query for combination of entities', () => {
const world = createWorld()
const query = new Query(world, [A, B, C])
Expand Down Expand Up @@ -207,4 +229,4 @@ function createWorld() {
}

return world
}
}
Loading
Loading