hmans / miniplex

A 👩‍💻 developer-friendly entity management system for 🕹 games and similarly demanding applications, based on 🛠 ECS architecture.
MIT License
854 stars 39 forks source link

`Query` (for 2.0) #275

Closed hmans closed 1 year ago

hmans commented 1 year ago

A new querying mechanism that is simpler to use, simpler to explain and document, and solves some of the issues of the purely predicate-based core API I've added for 2.0.

The goal is to keep the user-facing API largely the same conceptually (but a breaking change is fine), based off on Miniplex 2.0 Beta's current API:

const dying = world
  .with("health")
  .without("dead")
  .where(e => e.health < 10)

But instead of creating a set of nested archetypes (one bucket derived from world, the other derived from the first bucket), and an ad-hoc iterator that implements the low health condition (a functionality I've just removed from the library anyway), this will now always create (or re-use) a single instance of a new query bucket class.

Instances of these classes have knowledge of what archetypes they represent, which paves the way for some optimizations. For example, in the current version of Miniplex 2.0 beta, the following three statements, even though semantically representing exactly the same query, will create a total of 5 buckets:

world.with("foo").without("bar")
world.without("bar").with("foo")
world.archetype({ with: ["foo"], without: ["bar"] })

In a query object approach, we could do the kind of optimizations that detect that these actually represent the same query semantically, and then return memoized query buckets instead of creating new ones.

Suggested API

We want to simplify things, so instead of going crazy with nested buckets, let's reduce the whole setup to a builder-based approach, where the user

  1. Constructs a query
  2. Passes that query to world.query()

Query is a class that can be instantiated and exposes a query builder that consolidates added statements into its existing query state. The user may create such an object directly:

world.query(new Query().with("foo").without("bar").where(condition))

But we can also accept a function that receives a new Query instance and returns a Query instance to make the API nicer:

world.query(q => q.with("foo").without("bar").where(condition))

The world.query function returns a QueryBucket instance that receives entities based on the given query. It, too, exposes a query function to allow further nesting, but unlike the currently released beta, it will no longer nest buckets, it will simply create a new query based off of the state of the bucket's original query. Example:

const enemies = world.query(q => q.with("enemy"))
const deadEnemies = enemies.query(q => q.with("dead"))

Here, deadEnemies contains entities that have both the enemy and dead components, but the actual bucket sources its data directly from the world, instead of being nested under the enemies bucket. This means that if we at some other part of the project do this:

const dead = world.query(q => q.with("dead"))
const deadEnemies2 = dead.query(q => q.with("enemy"))

...deadEnemies and deadEnemies2 will contain exactly the same bucket object.