This is an entity component system to test some typing ideas and learn more about the ECS pattern.
An entity is simply an id corresponding to a collection of data objects called components. Each type of component has a unique key. We don't always want to iterate over the entire collection of components, but we want it to be fast. So we can think of Entity as a subtype of Map<Key, Object>
.
Generally we want to be able to ask the entity a couple things.
- Do you have a particular component?
- Give me the component corresponding to it's key.
- An Entity can have only 1 type of component. Ex: Position, Color, etc.
Consider the following code:
class Foo { name: string }
class Bar { age: number }
const m = new Map<string, object>();
m.add("foo", new Foo())
m.add("bar", new Bar())
if (m.has("foo"))
{
const foo = m.get("one")
foo.name = "foo"
}
If you put the above code into a ts file it should give you a type error for the statement foo.name = "foo"
. Namely, "it is possible that foo might be undefined".
Assuming this function is running in complete isolation and nothing is going to change between the has
and get
calls, the variable foo
should be able to be typed since we know the key "foo" exists in m, and the possible types that can be associated with the key does not include unknown.
In this implementation, instances of Components are still dumb objects (POJO). This is desirable in ECS for serializability purposes. In addition we are also using the component type as a key.
So instead of having to write something like:
entity.get(Text.key)
We can just write
entity.get(Text)
Under the hood, this is simply using the name property of the constructor for the key. Ideally, this would be a default, not required. But that wasn't a concern right now.
Consider the chunk of code below. It is similar to above, but written to utilize the ideas in Ntt.
import { Component, Universe } from "ntt"
class Foo implements Component
{
constructor(public name: string) {}
}
class Bar implements Component
{
constructor(public age: number) {}
}
class Position implements Component
{
constructor (public x: number, public y: number) { }
}
const u = new Universe()
const e = u.entity.create()
.with(Foo, "foo")
.with(Bar, "bar")
.build()
In the above code e
contains an entity with two components Foo
and Bar
.
Now we define some system:
universe.systems
/**
* A System for inspecting an entity
*/
.create(builder =>
builder
.setName("Inspect")
.query(qb => qb.all(Foo))
.addUpdateOne(({ entity }) =>
{
const foo = entity.get(Foo)
console.log(`Foo thing: ${foo.name}`)
if (foo.has(Pos))
{
const pos = entity.get(Pos)
console.log(`Foo is at pos: (${pos.x}, ${pos.y})`)
}
})
)
The main goal of this project is to look at a way to use conditional types and recursive types so this types appropriately.
We expect the check for the Pos
component to allow us to disregard checks for undefined / null if it returns true.
Currently the above goals are not met for a few reasons.
- There is no support for arbitrary generic parameters similar to how functions have a
... params: any[]
format (this could make the whole list / tuple stuff unnecessary). - The type
[Foo]
and[Foo, Bar]
do not have an "is assignable to" relation. - To fix 2 we can include the rest type parameter
... Component[]
in the tuple. This is the next avenue to explore a bit more. The issue comes from the covariance of theIEntity<T>
generic parameter and how type guards work. Instead of a simple casting ofthis
it is narrowing the type. Ex: If the type ofthis
isIEntity<[Foo]>
thenthis is IEntity<Cons<Bar, T>>
gives us the typeIEntity<[Bar, Foo]> & IEntity<[Foo]>
. Hence we need to be careful to make sure that we are creating a subtype. - Our attempt at fixing 3 was to try and create a new recursive type called
Has
which would insert the new type into the type list. The type on it's own worked but using it in conjunction withIEntity
was causing the typical "type instantiation is excessively deep" problems. - We will look at refactoring the IEntity stuff so we can better see where exactly these problems are popping up. In particular I am thinking the problem comes from all the recursive types being created when we are using
IEntity<Has<...>>
. So disconnecting these operations (specifically thehas
andget
) will allow us to try and create a more manageable test case.
There are some issues regarding the types for queries. If you do not use a query builder and just put a plain object into the EntityManager.query function the typing does not work out. So this should ideally provide a QueryBuilder instance to use.
A query has 3 possible parts to it. All, Any, or None.
- All: All of the components must be present.
- Any: At least one of the components must be present.
- None: None of the components should be present.
So for instance if we have a query that looks like { all: [A], any: [B, C], none: [D]}
this will return all entities that have component A and not D, and B or C.
This could be turned into some sort of predicate A & ~D & (B | C). So perhaps there is a way to specify this in the type parameters of IEntity. So we would end up with a type of the form IEntity<[A, B] | [A, C]>
, make it so the query { all: [A], none: [A]}
produces never
, etc.
We should be able to do this.
Not entirely satisfied with some of the naming of the types. So this should be rethought a little.