I am trying to design an entity-component system in typescript. My goal is to create a variadic generic abstract System
class that takes component classes as generic arguments.
The idea is that derived System
classes can be declared with the components they use. They can then use this information (the component class objects) to pull the subset of entities they operate on from the list of all entities. This subset is the set of entities that have the required components.
For context, I have included the component and the entity code first:
interface IComponent {
owner: Entity | null;
}
type ComponentClass<C extends IComponent> = new (args: unknown[]) => C;
abstract class Entity {
public readonly name: string;
/** The pixijs scene node (pixijs display object). */
private _sceneNode: unknown;
protected _components: IComponent[] = [];
constructor(name: string) {
this.name = name;
}
public get components(): IComponent[] {
return this._components;
}
public addComponent(component: IComponent): void {
this._components.push(component);
component.owner = this;
}
public getComponent<C extends IComponent>(componentClass: ComponentClass<C>): C {
for (const component of this._components) {
if (component instanceof componentClass) {
return component as C;
}
}
throw new Error(`component '${componentClass.name}' missing from entity ${this.constructor.name}`);
}
public removeComponent<C extends IComponent>(componentClass: ComponentClass<C>): void {
const removeList: number[] = [];
this._components.forEach((component, index) => {
if (component instanceof componentClass) {
removeList.push(index);
}
});
removeList.forEach(index => {
this._components.splice(index, 1);
});
}
public hasComponent<C extends IComponent>(componentClass: ComponentClass<C>): boolean {
return this._components.some(component => {
return component instanceof componentClass;
});
}
}
interface ISystem {
onSpawnEntity(entity: Entity): void;
onDestroyEntity(entity: Entity): void;
pullEntities(entities: Entity[]): void;
update(dt_s: number) : void;
}
I am trying to make this a variadic generic that can take an arbitrary number of component
classes, each of which extends IComponent
. Currently, it takes only 2, which demonstrates what I am trying to achieve:
abstract class System<C0 extends IComponent, C1 extends IComponent> implements ISystem {
protected _targets: [ComponentClass<C0>, ComponentClass<C1>];
protected _subjects: Entity[] = [];
constructor(targets: [ComponentClass<C0>, ComponentClass<C1>]) {
this._targets = targets;
}
public pullEntities(entities: Entity[]): void {
entities.forEach(entity => {
if(this.isEntitySubject(entity)) {
this._subjects.push(entity);
}
});
}
public onSpawnEntity(entity: Entity): void {
if(this.isEntitySubject(entity)) {
this._subjects.push(entity);
}
}
public onDestroyEntity(entity: Entity): void {
if(this.isEntitySubject(entity)) {
}
}
public update(dt_s: number) : void {
this._subjects.forEach(entity => {
const c0 = entity.getComponent(this._targets[0]);
const c1 = entity.getComponent(this._targets[1]);
this.updateEntity(c0, c1);
})
}
private isEntitySubject(entity: Entity): boolean {
return entity.hasComponent(this._targets[0]) &&
entity.hasComponent(this._targets[1]);
}
// the idea is that this is the only function systems will have to implement themselves,
// ideally want the args to be a variadic array of the component instances which the
// system uses.
protected updateEntity(c0: C0, c1: C1) {}
}
abstract class World
{
protected _systems: ISystem[] = [];
protected _entities: Entity[] = [];
public feedEntities(): void {
this._systems.forEach(system => {
system.pullEntities(this._entities);
});
}
public updateSystems(): void {
this._systems.forEach(system => {
system.update(20);
});
}
}
I have also included the example usage to provide more context:
////////////////////////////////////////////////////////////////////////////////
// EXAMPLE USAGE
////////////////////////////////////////////////////////////////////////////////
class PhysicsComponent implements IComponent {
public owner: Entity | null;
public x: number;
public y: number;
public mass: number;
}
class CollisionComponent implements IComponent {
public owner: Entity | null;
public bounds: {x: number, y: number, width: number, height: number};
}
// creating a new system, defining the component classes it takes.
class PhysicsSystem extends System<PhysicsComponent, CollisionComponent> {
protected updateEntity(physics: PhysicsComponent, collision: CollisionComponent) {
physics.x += 1;
physics.y += 1;
console.log(`entity: ${physics.owner.name} has position: {x: ${physics.x}, y: ${physics.y}}`);
}
}
class Person extends Entity {
constructor(name: string) {
super(name);
const physics = new PhysicsComponent();
physics.x = 20;
physics.y = 40;
this.addComponent(physics);
const collision = new CollisionComponent();
collision.bounds = {
x: 0, y: 0, width: 100, height: 100
};
this.addComponent(collision);
}
}
class GameWorld extends World {
constructor() {
super();
this._systems.push(new PhysicsSystem([PhysicsComponent, CollisionComponent]));
const e0 = new Person("jim");
const e1 = new Person("steve");
const e2 = new Person("sally");
this._entities.push(e0, e1, e2);
this.feedEntities();
}
}
const world = new GameWorld();
setInterval(() => {
world.updateSystems();
}, 300);
Is what I am trying to achieve even possible?
TypeScript has no "variadic generic", but it has Tuple types
So your System
class can be
type ComponentClasses<T extends IComponent[]> = { [K in keyof T]: T[K] extends IComponent ? ComponentClass<T[K]> : never };
abstract class System<TComponents extends IComponent[]> implements ISystem {
protected _targets: ComponentClasses<TComponents>;
protected _subjects: Entity[] = [];
constructor(targets: ComponentClasses<TComponents>) {
this._targets = targets;
}
protected abstract updateEntity(...components: TComponents): void;
}
Explanation:
TComponents extends IComponent[]
means TComponent
must be an array (or tuple) of IComponent
, for example, PhysicsComponent[]
or [PhysicsComponent, CollisionComponent]
(I didn't tested, but other parts of my code should only work with tuple type)TComponents
to their ComponentClass
es, I used a helper type ComponentClasses
, it's a Mapped type, especially, mapping a tuple type only maps its number keys, means ComponentClasses<[PhysicsComponent, CollisionComponent]>
will return [ComponentClass<PhysicsComponent>, ComponentClass<CollisionComponent>]
updateEntity
method accept variable number of arguments, Rest Parameters syntax is used. In TypeScript it allows tagging multiple parameters with a tuple type.Example of PhysicsSystem
:
class PhysicsSystem extends System<[PhysicsComponent, CollisionComponent]> {
protected override updateEntity(physics: PhysicsComponent, collision: CollisionComponent) {
physics.x += 1;
physics.y += 1;
console.log(`entity: ${physics.owner!.name} has position: {x: ${physics.x}, y: ${physics.y}}`);
}
}
If you change the type of physics
or collision
parameter, it won't compile.
In GameWorld
:
this._systems.push(new PhysicsSystem([PhysicsComponent, CollisionComponent]));
If you change the argument array, it won't compile either.