From 855ce516b6a2dd92cf8e622d2a2c06b0aa1f2932 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Sat, 5 Sep 2020 10:04:16 -0500 Subject: [PATCH] feat: Implement ECS Foundations (#1316) This is the ECS foundational work for Excalibur Related #1361 ## Changes: - New `Entity`, `Component`, `System`, `Query` and management types with tests --- CHANGELOG.md | 9 + src/engine/Actor.ts | 37 +-- src/engine/Engine.ts | 18 +- src/engine/EntityComponentSystem/Component.ts | 70 +++++ src/engine/EntityComponentSystem/Entity.ts | 281 ++++++++++++++++++ .../EntityComponentSystem/EntityManager.ts | 69 +++++ src/engine/EntityComponentSystem/Query.ts | 83 ++++++ .../EntityComponentSystem/QueryManager.ts | 99 ++++++ src/engine/EntityComponentSystem/System.ts | 87 ++++++ .../EntityComponentSystem/SystemManager.ts | 69 +++++ src/engine/EntityComponentSystem/Util.ts | 4 + src/engine/EntityComponentSystem/index.ts | 7 + src/engine/Events.ts | 48 +-- src/engine/Interfaces/LifecycleEvents.ts | 92 +++++- src/engine/Scene.ts | 32 +- src/engine/TileMap.ts | 14 +- src/engine/Util/Observable.ts | 29 ++ src/engine/Util/SortedList.ts | 72 ++--- src/engine/index.ts | 2 + src/engine/tsconfig.json | 2 +- src/spec/EntityManagerSpec.ts | 75 +++++ src/spec/EntitySpec.ts | 169 +++++++++++ src/spec/QueryManagerSpec.ts | 150 ++++++++++ src/spec/QuerySpec.ts | 136 +++++++++ src/spec/SortedListSpec.ts | 4 +- src/spec/SystemManagerSpec.ts | 132 ++++++++ src/spec/tsconfig.json | 2 +- 27 files changed, 1675 insertions(+), 117 deletions(-) create mode 100644 src/engine/EntityComponentSystem/Component.ts create mode 100644 src/engine/EntityComponentSystem/Entity.ts create mode 100644 src/engine/EntityComponentSystem/EntityManager.ts create mode 100644 src/engine/EntityComponentSystem/Query.ts create mode 100644 src/engine/EntityComponentSystem/QueryManager.ts create mode 100644 src/engine/EntityComponentSystem/System.ts create mode 100644 src/engine/EntityComponentSystem/SystemManager.ts create mode 100644 src/engine/EntityComponentSystem/Util.ts create mode 100644 src/engine/EntityComponentSystem/index.ts create mode 100644 src/engine/Util/Observable.ts create mode 100644 src/spec/EntityManagerSpec.ts create mode 100644 src/spec/EntitySpec.ts create mode 100644 src/spec/QueryManagerSpec.ts create mode 100644 src/spec/QuerySpec.ts create mode 100644 src/spec/SystemManagerSpec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a7fee901..5d7e49021 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Breaking Changes +- [#1361] Makes use of proxies, Excalibur longer supports IE11 :boom: ([#1361]https://github.com/excaliburjs/Excalibur/issues/1361) + ### Added +- Adds new ECS Foundations API, which allows excalibur core behavior to be manipulated with ECS style code ([#1361]https://github.com/excaliburjs/Excalibur/issues/1361) + - Adds new `ex.Entity` & `ex.EntityManager` which represent anything that can do something in a Scene and are containers for Components + - Adds new `ex.Component` type which allows encapsulation of state on entities + - Adds new `ex.Query` & `ex.QueryManager` which allows queries over entities that match a component list + - Adds new `ex.System` type which operates on matching Entities to do some behavior in Excalibur. + - Adds new `ex.Observable` a small observable implementation for observing Entity component changes over time + ### Changed ### Deprecated diff --git a/src/engine/Actor.ts b/src/engine/Actor.ts index 95126ef67..b09c2987b 100644 --- a/src/engine/Actor.ts +++ b/src/engine/Actor.ts @@ -1,4 +1,3 @@ -import { Class } from './Class'; import { Texture } from './Resources/Texture'; import { InitializeEvent, @@ -47,6 +46,8 @@ import { obsolete } from './Util/Decorators'; import { Collider } from './Collision/Collider'; import { Shape } from './Collision/Shape'; +import { Entity } from './EntityComponentSystem/Entity'; + export function isActor(x: any): x is Actor { return x instanceof Actor; } @@ -79,7 +80,7 @@ export interface ActorDefaults { * @hidden */ -export class ActorImpl extends Class implements Actionable, Eventable, PointerEvents, CanInitialize, CanUpdate, CanDraw, CanBeKilled { +export class ActorImpl extends Entity implements Actionable, Eventable, PointerEvents, CanInitialize, CanUpdate, CanDraw, CanBeKilled { // #region Properties /** @@ -352,7 +353,6 @@ export class ActorImpl extends Class implements Actionable, Eventable, PointerEv */ public children: Actor[] = []; - private _isInitialized: boolean = false; public frames: { [key: string]: Drawable } = {}; /** @@ -535,13 +535,6 @@ export class ActorImpl extends Class implements Actionable, Eventable, PointerEv // Override me } - /** - * Gets whether the actor is Initialized - */ - public get isInitialized(): boolean { - return this._isInitialized; - } - /** * Initializes this actor and all it's child actors, meant to be called by the Scene before first update not by users of Excalibur. * @@ -550,11 +543,7 @@ export class ActorImpl extends Class implements Actionable, Eventable, PointerEv * @internal */ public _initialize(engine: Engine) { - if (!this.isInitialized) { - this.onInitialize(engine); - super.emit('initialize', new InitializeEvent(engine, this)); - this._isInitialized = true; - } + super._initialize(engine); for (const child of this.children) { child._initialize(engine); } @@ -654,9 +643,9 @@ export class ActorImpl extends Class implements Actionable, Eventable, PointerEv public on(eventName: Events.kill, handler: (event: KillEvent) => void): void; public on(eventName: Events.prekill, handler: (event: PreKillEvent) => void): void; public on(eventName: Events.postkill, handler: (event: PostKillEvent) => void): void; - public on(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; - public on(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; - public on(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; + public on(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; + public on(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; + public on(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; public on(eventName: Events.predraw, handler: (event: PreDrawEvent) => void): void; public on(eventName: Events.postdraw, handler: (event: PostDrawEvent) => void): void; public on(eventName: Events.predebugdraw, handler: (event: PreDebugDrawEvent) => void): void; @@ -721,9 +710,9 @@ export class ActorImpl extends Class implements Actionable, Eventable, PointerEv public once(eventName: Events.kill, handler: (event: KillEvent) => void): void; public once(eventName: Events.postkill, handler: (event: PostKillEvent) => void): void; public once(eventName: Events.prekill, handler: (event: PreKillEvent) => void): void; - public once(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; - public once(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; - public once(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; + public once(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; + public once(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; + public once(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; public once(eventName: Events.predraw, handler: (event: PreDrawEvent) => void): void; public once(eventName: Events.postdraw, handler: (event: PostDrawEvent) => void): void; public once(eventName: Events.predebugdraw, handler: (event: PreDebugDrawEvent) => void): void; @@ -799,9 +788,9 @@ export class ActorImpl extends Class implements Actionable, Eventable, PointerEv public off(eventName: Events.pointerdragmove, handler?: (event: PointerDragEvent) => void): void; public off(eventName: Events.prekill, handler?: (event: PreKillEvent) => void): void; public off(eventName: Events.postkill, handler?: (event: PostKillEvent) => void): void; - public off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; - public off(eventName: Events.postupdate, handler?: (event: Events.PostUpdateEvent) => void): void; - public off(eventName: Events.preupdate, handler?: (event: Events.PreUpdateEvent) => void): void; + public off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; + public off(eventName: Events.postupdate, handler?: (event: Events.PostUpdateEvent) => void): void; + public off(eventName: Events.preupdate, handler?: (event: Events.PreUpdateEvent) => void): void; public off(eventName: Events.postdraw, handler?: (event: Events.PostDrawEvent) => void): void; public off(eventName: Events.predraw, handler?: (event: Events.PreDrawEvent) => void): void; public off(eventName: Events.enterviewport, handler?: (event: EnterViewPortEvent) => void): void; diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index f3a16687e..a8a7b6a91 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -375,13 +375,13 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { private _isInitialized: boolean = false; - public on(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; + public on(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; public on(eventName: Events.visible, handler: (event: VisibleEvent) => void): void; public on(eventName: Events.hidden, handler: (event: HiddenEvent) => void): void; public on(eventName: Events.start, handler: (event: GameStartEvent) => void): void; public on(eventName: Events.stop, handler: (event: GameStopEvent) => void): void; - public on(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; - public on(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; + public on(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; + public on(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; public on(eventName: Events.preframe, handler: (event: PreFrameEvent) => void): void; public on(eventName: Events.postframe, handler: (event: PostFrameEvent) => void): void; public on(eventName: Events.predraw, handler: (event: PreDrawEvent) => void): void; @@ -391,13 +391,13 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { super.on(eventName, handler); } - public once(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; + public once(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; public once(eventName: Events.visible, handler: (event: VisibleEvent) => void): void; public once(eventName: Events.hidden, handler: (event: HiddenEvent) => void): void; public once(eventName: Events.start, handler: (event: GameStartEvent) => void): void; public once(eventName: Events.stop, handler: (event: GameStopEvent) => void): void; - public once(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; - public once(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; + public once(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; + public once(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; public once(eventName: Events.preframe, handler: (event: PreFrameEvent) => void): void; public once(eventName: Events.postframe, handler: (event: PostFrameEvent) => void): void; public once(eventName: Events.predraw, handler: (event: PreDrawEvent) => void): void; @@ -407,13 +407,13 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { super.once(eventName, handler); } - public off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; + public off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; public off(eventName: Events.visible, handler?: (event: VisibleEvent) => void): void; public off(eventName: Events.hidden, handler?: (event: HiddenEvent) => void): void; public off(eventName: Events.start, handler?: (event: GameStartEvent) => void): void; public off(eventName: Events.stop, handler?: (event: GameStopEvent) => void): void; - public off(eventName: Events.preupdate, handler?: (event: PreUpdateEvent) => void): void; - public off(eventName: Events.postupdate, handler?: (event: PostUpdateEvent) => void): void; + public off(eventName: Events.preupdate, handler?: (event: PreUpdateEvent) => void): void; + public off(eventName: Events.postupdate, handler?: (event: PostUpdateEvent) => void): void; public off(eventName: Events.preframe, handler?: (event: PreFrameEvent) => void): void; public off(eventName: Events.postframe, handler?: (event: PostFrameEvent) => void): void; public off(eventName: Events.predraw, handler?: (event: PreDrawEvent) => void): void; diff --git a/src/engine/EntityComponentSystem/Component.ts b/src/engine/EntityComponentSystem/Component.ts new file mode 100644 index 000000000..5ac7e2887 --- /dev/null +++ b/src/engine/EntityComponentSystem/Component.ts @@ -0,0 +1,70 @@ +import { Entity } from './Entity'; + +export interface ComponentCtor { + new (): Component; +} + +function hasClone(x: any): x is { clone(): any } { + return !!x?.clone; +} + +/** + * Components are containers for state in Excalibur, the are meant to convey capabilities that an Entity posesses + * + * Implementations of Component must have a zero-arg constructor + */ +export abstract class Component { + /** + * Optionally list any component types this component depends on + * If the owner entity does not have these components, new components will be added to the entity + * + * Only components with zero-arg constructors are supported as automatic component dependencies + */ + readonly dependencies?: ComponentCtor[]; + + /** + * Type of this component, must be a unique type among component types in you game. + * See [[BuiltinComponentTypes]] for a list of built in excalibur types + */ + abstract readonly type: TypeName; + + /** + * Current owning [[Entity]], if any, of this component. Null if not added to any [[Entity]] + */ + owner?: Entity; + clone(): this { + const newComponent = new (this.constructor as any)(); + for (const prop in this) { + if (this.hasOwnProperty(prop)) { + const val = this[prop]; + if (hasClone(val) && prop !== 'owner' && prop !== 'clone') { + newComponent[prop] = val.clone(); + } else { + newComponent[prop] = val; + } + } + } + return newComponent; + } + + /** + * Optional callback called when a component is added to an entity + */ + onAdd?: (owner: Entity) => void; + + /** + * Opitonal callback called when acomponent is added to an entity + */ + onRemove?: (previousOwner: Entity) => void; +} + +/** + * Tag components are a way of tagging a component with label and a simple value + */ +export class TagComponent extends Component< +TypeName +> { + constructor(public readonly type: TypeName, public readonly value?: MaybeValueType) { + super(); + } +} diff --git a/src/engine/EntityComponentSystem/Entity.ts b/src/engine/EntityComponentSystem/Entity.ts new file mode 100644 index 000000000..2bdb61f95 --- /dev/null +++ b/src/engine/EntityComponentSystem/Entity.ts @@ -0,0 +1,281 @@ +import { Component } from './Component'; + +import { Observable, Message } from '../Util/Observable'; +import { Class } from '../Class'; +import { OnInitialize, OnPreUpdate, OnPostUpdate } from '../Interfaces/LifecycleEvents'; +import { Engine } from '../Engine'; +import { InitializeEvent, PreUpdateEvent, PostUpdateEvent } from '../Events'; + +export interface EntityComponent { + component: Component; + entity: Entity; +} +export class AddedComponent implements Message { + readonly type: 'Component Added' = 'Component Added'; + constructor(public data: EntityComponent) {} +} + +export function isAddedComponent(x: Message): x is AddedComponent { + return !!x && x.type === 'Component Added'; +} + +export class RemovedComponent implements Message { + readonly type: 'Component Removed' = 'Component Removed'; + constructor(public data: EntityComponent) {} +} + +export function isRemovedComponent(x: Message): x is RemovedComponent { + return !!x && x.type === 'Component Removed'; +} + +export type ComponentMap = { [type: string]: Component }; + +// Given a TypeName string (Component.type), find the ComponentType that goes with that type name +export type MapTypeNameToComponent = + // If the ComponentType is a Component with type = TypeName then that's the type we are looking for + ComponentType extends Component ? ComponentType : never; + +// Given a type union of PossibleComponentTypes, create a dictionary that maps that type name string to those individual types +export type ComponentMapper = { + [TypeName in PossibleComponentTypes['type']]: MapTypeNameToComponent; +} & +ComponentMap; + +export type ExcludeType = TypeNameOrType extends string + ? Exclude> + : Exclude; + +export class Entity extends Class implements OnInitialize, OnPreUpdate, OnPostUpdate { + private static _ID = 0; + + /** + * The unique identifier for the entity + */ + public id: number = Entity._ID++; + + /** + * Whether this entity is active, if set to false it will be reclaimed + */ + public active: boolean = true; + + public kill() { + this.active = false; + } + + public isKilled() { + return !this.active; + } + + private _componentsToRemove: (Component | string)[] = []; + + /** + * The types of the components on the Entity + */ + public get types(): string[] { + return this._dirty ? (this._typesMemo = Object.keys(this.components)) : this._typesMemo; + } + private _typesMemo: string[] = []; + private _dirty = true; + + private _handleChanges = { + defineProperty: (obj: any, prop: any, descriptor: PropertyDescriptor) => { + obj[prop] = descriptor.value; + this.changes.notifyAll( + new AddedComponent({ + component: descriptor.value as Component, + entity: this + }) + ); + return true; + }, + deleteProperty: (obj: any, prop: any) => { + if (prop in obj) { + this.changes.notifyAll( + new RemovedComponent({ + component: obj[prop] as Component, + entity: this + }) + ); + delete obj[prop]; + return true; + } + return false; + } + }; + + public components = new Proxy>({} as any, this._handleChanges); + + public changes = new Observable(); + + /** + * Creates a deep copy of the entity and a copy of all its components + */ + public clone(): Entity { + const newEntity = new Entity(); + for (const c of this.types) { + newEntity.addComponent(this.components[c].clone()); + } + return newEntity; + } + + public addComponent(componentOrEntity: T | Entity, force: boolean = false): Entity { + // If you use an entity as a "prefab" or template + if (componentOrEntity instanceof Entity) { + for (const c in componentOrEntity.components) { + this.addComponent(componentOrEntity.components[c].clone()); + } + // Normal component case + } else { + // if component already exists, skip if not forced + if (this.components[componentOrEntity.type] && !force) { + return this as Entity; + } + + // Remove existing component type if exists when forced + if (this.components[componentOrEntity.type] && force) { + this.removeComponent(componentOrEntity); + } + + // todo circular dependencies will be a problem + if (componentOrEntity.dependencies && componentOrEntity.dependencies.length) { + for (const ctor of componentOrEntity.dependencies) { + this.addComponent(new ctor()); + this._dirty = true; + } + } + + componentOrEntity.owner = this; + (this.components as ComponentMap)[componentOrEntity.type] = componentOrEntity; + if (componentOrEntity.onAdd) { + this._dirty = true; + componentOrEntity.onAdd(this); + } + } + return this as Entity; + } + + /** + * Removes a component from the entity, by default removals are deferred to the end of entity processing to avoid consistency issues + * + * Components can be force removed with the `force` flag, the removal is not deferred and happens immediately + * @param componentOrType + * @param force + */ + public removeComponent( + componentOrType: ComponentOrType, + force = false + ): Entity> { + if (force) { + if (typeof componentOrType === 'string') { + this._removeComponentByType(componentOrType); + } else if (componentOrType instanceof Component) { + this._removeComponentByType(componentOrType.type); + } + } else { + this._componentsToRemove.push(componentOrType); + } + + return this as any; + } + + private _removeComponentByType(type: string) { + if (this.components[type]) { + this.components[type].owner = null; + if (this.components[type].onRemove) { + this.components[type].onRemove(this); + } + delete this.components[type]; + this._dirty = true; + } + } + + /** + * @hidden + * @internal + */ + public processRemoval() { + for (const componentOrType of this._componentsToRemove) { + const type = typeof componentOrType === 'string' ? componentOrType : componentOrType.type; + this._removeComponentByType(type); + } + this._componentsToRemove.length = 0; + } + + public has(type: string): boolean { + return !!this.components[type]; + } + + private _isInitialized = false; + + /** + * Gets whether the actor is Initialized + */ + public get isInitialized(): boolean { + return this._isInitialized; + } + + /** + * Initializes this entity, meant to be called by the Scene before first update not by users of Excalibur. + * + * It is not recommended that internal excalibur methods be overriden, do so at your own risk. + * + * @internal + */ + public _initialize(engine: Engine) { + if (!this.isInitialized) { + this.onInitialize(engine); + super.emit('initialize', new InitializeEvent(engine, this)); + this._isInitialized = true; + } + } + + /** + * It is not recommended that internal excalibur methods be overriden, do so at your own risk. + * + * Internal _preupdate handler for [[onPreUpdate]] lifecycle event + * @internal + */ + public _preupdate(engine: Engine, delta: number): void { + this.emit('preupdate', new PreUpdateEvent(engine, delta, this)); + this.onPreUpdate(engine, delta); + } + + /** + * It is not recommended that internal excalibur methods be overriden, do so at your own risk. + * + * Internal _preupdate handler for [[onPostUpdate]] lifecycle event + * @internal + */ + public _postupdate(engine: Engine, delta: number): void { + this.emit('postupdate', new PostUpdateEvent(engine, delta, this)); + this.onPostUpdate(engine, delta); + } + + /** + * `onInitialize` is called before the first update of the entity. This method is meant to be + * overridden. + * + * Synonymous with the event handler `.on('initialize', (evt) => {...})` + */ + public onInitialize(_engine: Engine): void { + // Override me + } + + /** + * Safe to override onPreUpdate lifecycle event handler. Synonymous with `.on('preupdate', (evt) =>{...})` + * + * `onPreUpdate` is called directly before an entity is updated. + */ + public onPreUpdate(_engine: Engine, _delta: number): void { + // Override me + } + + /** + * Safe to override onPostUpdate lifecycle event handler. Synonymous with `.on('postupdate', (evt) =>{...})` + * + * `onPostUpdate` is called directly after an entity is updated. + */ + public onPostUpdate(_engine: Engine, _delta: number): void { + // Override me + } +} diff --git a/src/engine/EntityComponentSystem/EntityManager.ts b/src/engine/EntityComponentSystem/EntityManager.ts new file mode 100644 index 000000000..6f6a27894 --- /dev/null +++ b/src/engine/EntityComponentSystem/EntityManager.ts @@ -0,0 +1,69 @@ +import { Entity, RemovedComponent, AddedComponent, isAddedComponent, isRemovedComponent } from './Entity'; +import { Observer } from '../Util/Observable'; +import { Util } from '..'; +import { Scene } from '../Scene'; + +// Add/Remove entitys and components + +export class EntityManager implements Observer { + public entities: Entity[] = []; + public _entityIndex: { [entityId: string]: Entity } = {}; + + constructor(private _scene: Scene) {} + + /** + * EntityManager observes changes on entities + * @param message + */ + public notify(message: RemovedComponent | AddedComponent): void { + if (isAddedComponent(message)) { + // we don't need the component, it's already on the entity + this._scene.queryManager.addEntity(message.data.entity); + } + + if (isRemovedComponent(message)) { + this._scene.queryManager.removeComponent(message.data.entity, message.data.component); + } + } + + /** + * Adds an entity to be tracked by the EntityManager + * @param entity + */ + public addEntity(entity: Entity): void { + if (entity) { + this._entityIndex[entity.id] = entity; + this.entities.push(entity); + this._scene.queryManager.addEntity(entity); + entity.changes.register(this); + } + } + + public removeEntity(entity: Entity): void; + public removeEntity(id: number): void; + public removeEntity(idOrEntity: number | Entity): void { + let id = 0; + if (idOrEntity instanceof Entity) { + id = idOrEntity.id; + } else { + id = idOrEntity; + } + const entity = this._entityIndex[id]; + delete this._entityIndex[id]; + if (entity) { + Util.removeItemFromArray(entity, this.entities); + this._scene.queryManager.removeEntity(entity); + entity.changes.unregister(this); + } + } + + public processRemovals(): void { + for (const entity of this.entities) { + entity.processRemoval(); + } + } + + public getById(id: number): Entity { + return this._entityIndex[id]; + } +} diff --git a/src/engine/EntityComponentSystem/Query.ts b/src/engine/EntityComponentSystem/Query.ts new file mode 100644 index 000000000..1989e90f8 --- /dev/null +++ b/src/engine/EntityComponentSystem/Query.ts @@ -0,0 +1,83 @@ +import { Entity } from './Entity'; +import { buildTypeKey } from './Util'; +import { Observable } from '../Util/Observable'; +import { Util, Component } from '..'; +import { AddedEntity, RemovedEntity } from './System'; + +/** + * Represents query for entities that match a list of types that is cached and observable + * + * Queries can be strongly typed by supplying a type union in the optional type parameter + * ```typescript + * const queryAB = new ex.Query(['A', 'B']); + * ``` + */ +export class Query extends Observable { + public entities: Entity[] = []; + public get key(): string { + return buildTypeKey(this.types); + } + + public constructor(public types: string[]) { + super(); + } + + /** + * Add an entity to the query, will only be added if the entity matches the query types + * @param entity + */ + public addEntity(entity: Entity): void { + if (!Util.contains(this.entities, entity) && this.matches(entity)) { + this.entities.push(entity); + this.notifyAll(new AddedEntity(entity)); + } + } + + /** + * If the entity is part of the query it will be removed regardless of types + * @param entity + */ + public removeEntity(entity: Entity): void { + if (Util.removeItemFromArray(entity, this.entities)) { + this.notifyAll(new RemovedEntity(entity)); + } + } + + /** + * Removes all entities and observers from the query + */ + public clear(): void { + this.entities.length = 0; + for (const observer of this.observers) { + this.unregister(observer); + } + } + + /** + * Returns whether the entity's types match query + * @param entity + */ + public matches(entity: Entity): boolean; + /** + * Returns whether the list of ComponentTypes match the query + * @param types + */ + public matches(types: string[]): boolean; + public matches(typesOrEntity: string[] | Entity): boolean { + let types: string[] = []; + if (typesOrEntity instanceof Entity) { + types = typesOrEntity.types; + } else { + types = typesOrEntity; + } + + let matches = true; + for (const type of this.types) { + matches = matches && types.indexOf(type) > -1; + if (!matches) { + return false; + } + } + return matches; + } +} diff --git a/src/engine/EntityComponentSystem/QueryManager.ts b/src/engine/EntityComponentSystem/QueryManager.ts new file mode 100644 index 000000000..06cddaf7a --- /dev/null +++ b/src/engine/EntityComponentSystem/QueryManager.ts @@ -0,0 +1,99 @@ +import { Entity } from './Entity'; +import { buildTypeKey } from './Util'; +import { Query } from './Query'; +import { Component } from './Component'; +import { Scene } from '../Scene'; + +/** + * The query manager is responsible for updating all queries when entities/components change + */ +export class QueryManager { + private _queries: { [entityComponentKey: string]: Query } = {}; + + constructor(public scene: Scene) {} + + /** + * Adds a query to the manager and populates with any entities that match + * @param query + */ + private _addQuery(query: Query) { + this._queries[buildTypeKey(query.types)] = query; + for (const entity of this.scene.entityManager.entities) { + query.addEntity(entity); + } + } + + /** + * Removes the query if there are no observers left + * @param query + */ + public maybeRemoveQuery(query: Query): void { + if (query.observers.length === 0) { + query.clear(); + delete this._queries[buildTypeKey(query.types)]; + } + } + + /** + * Adds the entity to any matching query in the query manage + * @param entity + */ + public addEntity(entity: Entity) { + for (const queryType in this._queries) { + if (this._queries[queryType]) { + this._queries[queryType].addEntity(entity); + } + } + } + + /** + * Removes an entity from queries if the removed component disqualifies it + * @param entity + * @param component + */ + public removeComponent(entity: Entity, component: Component) { + for (const queryType in this._queries) { + if (this._queries[queryType].matches(entity.types.concat([component.type]))) { + this._queries[queryType].removeEntity(entity); + } + } + } + + /** + * Removes an entity from all queries it is currently a part of + * @param entity + */ + public removeEntity(entity: Entity) { + for (const queryType in this._queries) { + if (this._queries[queryType].entities.indexOf(entity) > -1) { + this._queries[queryType].removeEntity(entity); + } + } + } + + /** + * Creates a populated query and returns, if the query already exists that will be returned instead of a new instance + * @param types + */ + public createQuery(types: string[]): Query { + const maybeExistingQuery = this.getQuery(types); + if (maybeExistingQuery) { + return maybeExistingQuery; + } + const query = new Query(types); + this._addQuery(query); + return query; + } + + /** + * Retrieves an existing query by types if it exists otherwise returns null + * @param types + */ + public getQuery(types: string[]): Query { + const key = buildTypeKey(types); + if (this._queries[key]) { + return this._queries[key] as Query; + } + return null; + } +} diff --git a/src/engine/EntityComponentSystem/System.ts b/src/engine/EntityComponentSystem/System.ts new file mode 100644 index 000000000..a5454a966 --- /dev/null +++ b/src/engine/EntityComponentSystem/System.ts @@ -0,0 +1,87 @@ +import { Entity } from './Entity'; +import { Engine } from '../Engine'; +import { Message, Observer } from '../Util/Observable'; +import { Component } from './Component'; + +export enum SystemType { + Update = 'update', + Draw = 'draw' +} + +/** + * An Excalibur [[System]] that updates entities of certain types. + * Systems are scene specific + */ +export abstract class System implements Observer { + /** + * The types of entities that this system operates on + */ + abstract readonly types: string[]; + + abstract readonly systemType: SystemType; + + /** + * System can execute in priority order, by default all systems are priority 0. Lower values indicated higher priority. + * For a system to execute before all other a lower priority value (-1 for example) must be set. + * For a system to exectue after all other a higher priority value (10 for example) must be set. + */ + public priority: number = 0; + + /** + * Update all entities that match this system's types + * @param entities Entities to update that match this system's typse + * @param delta Time in milliseconds + */ + abstract update(entities: Entity[], delta: number): void; + + /** + * Optionally run a preupdate before the system processes matching entities + * @param engine + * @param delta Time in milliseconds since the last frame + */ + preupdate?: (engine: Engine, delta: number) => void; + + /** + * Optionally run a postupdate after the system processes matching entities + * @param engine + * @param delta Time in milliseconds since the last frame + */ + postupdate?: (engine: Engine, delta: number) => void; + + /** + * Optionally run a debug draw step to visualize the internals of the system + */ + debugDraw?: (ctx: CanvasRenderingContext2D, delta: number) => void; + + /** + * Systems observe when entities match their types or no longer match their types, override + * @param _entityAddedOrRemoved + */ + public notify(_entityAddedOrRemoved: AddedEntity | RemovedEntity) { + // Override me + } +} + +/** + * An [[Entity]] with [[Component]] types that matches a [[System]] types exists in the current scene. + */ +export class AddedEntity implements Message { + readonly type: 'Entity Added' = 'Entity Added'; + constructor(public data: Entity) {} +} + +export function isAddedSystemEntity(x: Message): x is AddedEntity { + return !!x && x.type === 'Entity Added'; +} + +/** + * An [[Entity]] with [[Component]] types that no longer matches a [[System]] types exists in the current scene. + */ +export class RemovedEntity implements Message { + readonly type: 'Entity Removed' = 'Entity Removed'; + constructor(public data: Entity) {} +} + +export function isRemoveSystemEntity(x: Message): x is RemovedEntity { + return !!x && x.type === 'Entity Removed'; +} diff --git a/src/engine/EntityComponentSystem/SystemManager.ts b/src/engine/EntityComponentSystem/SystemManager.ts new file mode 100644 index 000000000..387c67e90 --- /dev/null +++ b/src/engine/EntityComponentSystem/SystemManager.ts @@ -0,0 +1,69 @@ +import { System, SystemType } from './System'; +import { Engine } from '../Engine'; +import { Util, Component } from '..'; +import { Scene } from '../Scene'; + +/** + * The SystemManager is responsible for keeping track of all systems in a scene. + * Systems are scene specific + */ +export class SystemManager { + public systems: System[] = []; + public _keyToSystem: { [key: string]: System }; + constructor(private _scene: Scene) {} + + /** + * Adds a system to the manager, it will now be updated every frame + * @param system + */ + public addSystem(system: System): void { + // validate system has types + if (!system.types || system.types.length === 0) { + throw new Error(`Attempted to add a System without any types`); + } + + const query = this._scene.queryManager.createQuery(system.types); + this.systems.push(system); + // TODO polyfil stable .sort(), this mechanism relies on a stable sort + this.systems.sort((a, b) => a.priority - b.priority); + query.register(system); + } + + /** + * Removes a system from the manager, it will no longer be updated + * @param system + */ + public removeSystem(system: System) { + Util.removeItemFromArray(system, this.systems); + const query = this._scene.queryManager.getQuery(system.types); + if (query) { + query.unregister(system); + this._scene.queryManager.maybeRemoveQuery(query); + } + } + + /** + * Updates all systems + * @param engine + * @param delta + */ + public updateSystems(type: SystemType, engine: Engine, delta: number) { + const systems = this.systems.filter((s) => s.systemType === type); + for (const s of systems) { + if (s.preupdate) { + s.preupdate(engine, delta); + } + } + + for (const s of systems) { + const entities = this._scene.queryManager.getQuery(s.types).entities; + s.update(entities, delta); + } + + for (const s of systems) { + if (s.postupdate) { + s.postupdate(engine, delta); + } + } + } +} diff --git a/src/engine/EntityComponentSystem/Util.ts b/src/engine/EntityComponentSystem/Util.ts new file mode 100644 index 000000000..b5b503f3e --- /dev/null +++ b/src/engine/EntityComponentSystem/Util.ts @@ -0,0 +1,4 @@ +export const buildTypeKey = (types: string[]) => { + const key = types.sort((a, b) => a.localeCompare(b)).join('+'); + return key; +}; diff --git a/src/engine/EntityComponentSystem/index.ts b/src/engine/EntityComponentSystem/index.ts new file mode 100644 index 000000000..bbf43fd53 --- /dev/null +++ b/src/engine/EntityComponentSystem/index.ts @@ -0,0 +1,7 @@ +export * from './Component'; +export * from './Entity'; +export * from './EntityManager'; +export * from './Query'; +export * from './QueryManager'; +export * from './System'; +export * from './SystemManager'; diff --git a/src/engine/Events.ts b/src/engine/Events.ts index 70b9b5795..39af3bc3e 100644 --- a/src/engine/Events.ts +++ b/src/engine/Events.ts @@ -7,8 +7,10 @@ import { Engine } from './Engine'; import { TileMap } from './TileMap'; import { Side } from './Collision/Side'; import * as Input from './Input/Index'; -import { Pair, Camera } from './index'; +import { Pair } from './index'; import { Collider } from './Collision/Collider'; +import { Entity } from './EntityComponentSystem/Entity'; +import { OnInitialize, OnPreUpdate, OnPostUpdate } from './Interfaces/LifecycleEvents'; export enum EventTypes { Kill = 'kill', @@ -241,8 +243,8 @@ export class GameStopEvent extends GameEvent { * transform so that all drawing takes place with the actor as the origin. * */ -export class PreDrawEvent extends GameEvent { - constructor(public ctx: CanvasRenderingContext2D, public delta: number, public target: Actor | Scene | Engine | TileMap) { +export class PreDrawEvent extends GameEvent { + constructor(public ctx: CanvasRenderingContext2D, public delta: number, public target: Entity | Scene | Engine | TileMap) { super(); } } @@ -252,8 +254,8 @@ export class PreDrawEvent extends GameEvent { * transform so that all drawing takes place with the actor as the origin. * */ -export class PostDrawEvent extends GameEvent { - constructor(public ctx: CanvasRenderingContext2D, public delta: number, public target: Actor | Scene | Engine | TileMap) { +export class PostDrawEvent extends GameEvent { + constructor(public ctx: CanvasRenderingContext2D, public delta: number, public target: Entity | Scene | Engine | TileMap) { super(); } } @@ -261,8 +263,8 @@ export class PostDrawEvent extends GameEvent { /** * The 'predebugdraw' event is emitted on actors, scenes, and engine before debug drawing starts. */ -export class PreDebugDrawEvent extends GameEvent { - constructor(public ctx: CanvasRenderingContext2D, public target: Actor | Scene | Engine) { +export class PreDebugDrawEvent extends GameEvent { + constructor(public ctx: CanvasRenderingContext2D, public target: Entity | Actor | Scene | Engine) { super(); } } @@ -270,8 +272,8 @@ export class PreDebugDrawEvent extends GameEvent { /** * The 'postdebugdraw' event is emitted on actors, scenes, and engine after debug drawing starts. */ -export class PostDebugDrawEvent extends GameEvent { - constructor(public ctx: CanvasRenderingContext2D, public target: Actor | Scene | Engine) { +export class PostDebugDrawEvent extends GameEvent { + constructor(public ctx: CanvasRenderingContext2D, public target: Entity | Actor | Scene | Engine) { super(); } } @@ -279,8 +281,8 @@ export class PostDebugDrawEvent extends GameEvent { /** * The 'preupdate' event is emitted on actors, scenes, camera, and engine before the update starts. */ -export class PreUpdateEvent extends GameEvent { - constructor(public engine: Engine, public delta: number, public target: Actor | Scene | Engine | TileMap | Camera) { +export class PreUpdateEvent extends GameEvent { + constructor(public engine: Engine, public delta: number, public target: T) { super(); } } @@ -288,8 +290,8 @@ export class PreUpdateEvent extends GameEvent { - constructor(public engine: Engine, public delta: number, public target: Actor | Scene | Engine | TileMap | Camera) { +export class PostUpdateEvent extends GameEvent { + constructor(public engine: Engine, public delta: number, public target: T) { super(); } } @@ -401,7 +403,7 @@ export class HiddenEvent extends GameEvent { /** * Event thrown on an [[Actor|actor]] when a collision will occur this frame if it resolves */ -export class PreCollisionEvent extends GameEvent { +export class PreCollisionEvent extends GameEvent { /** * @param actor The actor the event was thrown on * @param other The actor that will collided with the current actor @@ -425,7 +427,7 @@ export class PreCollisionEvent extends GameE /** * Event thrown on an [[Actor|actor]] when a collision has been resolved (body reacted) this frame */ -export class PostCollisionEvent extends GameEvent { +export class PostCollisionEvent extends GameEvent { /** * @param actor The actor the event was thrown on * @param other The actor that did collide with the current actor @@ -449,7 +451,7 @@ export class PostCollisionEvent extends Game /** * Event thrown the first time an [[Actor|actor]] collides with another, after an actor is in contact normal collision events are fired. */ -export class CollisionStartEvent extends GameEvent { +export class CollisionStartEvent extends GameEvent { /** * * @param actor @@ -473,7 +475,7 @@ export class CollisionStartEvent extends Gam /** * Event thrown when the [[Actor|actor]] is no longer colliding with another */ -export class CollisionEndEvent extends GameEvent { +export class CollisionEndEvent extends GameEvent { /** * */ @@ -494,11 +496,11 @@ export class CollisionEndEvent extends GameE /** * Event thrown on an [[Actor]] and a [[Scene]] only once before the first update call */ -export class InitializeEvent extends GameEvent { +export class InitializeEvent extends GameEvent { /** * @param engine The reference to the current engine */ - constructor(public engine: Engine, public target: Actor | Scene | Engine | Camera) { + constructor(public engine: Engine, public target: T) { super(); } } @@ -530,8 +532,8 @@ export class DeactivateEvent extends GameEvent { /** * Event thrown on an [[Actor]] when it completely leaves the screen. */ -export class ExitViewPortEvent extends GameEvent { - constructor(public target: Actor) { +export class ExitViewPortEvent extends GameEvent { + constructor(public target: Entity) { super(); } } @@ -539,8 +541,8 @@ export class ExitViewPortEvent extends GameEvent { /** * Event thrown on an [[Actor]] when it completely leaves the screen. */ -export class EnterViewPortEvent extends GameEvent { - constructor(public target: Actor) { +export class EnterViewPortEvent extends GameEvent { + constructor(public target: Entity) { super(); } } diff --git a/src/engine/Interfaces/LifecycleEvents.ts b/src/engine/Interfaces/LifecycleEvents.ts index ced7c6628..4ae77e657 100644 --- a/src/engine/Interfaces/LifecycleEvents.ts +++ b/src/engine/Interfaces/LifecycleEvents.ts @@ -2,6 +2,57 @@ import { Engine } from './../Engine'; import * as Events from './../Events'; import { Scene } from '../Scene'; +// eslint-disable-next-line @typescript-eslint/class-name-casing +export interface _initialize { + _initialize(engine: Engine): void; +} + +export function has_initialize(a: any): a is _initialize { + return !!a._initialize; +} + +export interface OnInitialize { + onInitialize(engine: Engine): void; +} + +export function hasOnInitialize(a: any): a is OnInitialize { + return !!a.onInitialize; +} + +// eslint-disable-next-line @typescript-eslint/class-name-casing +export interface _preupdate { + _preupdate(engine: Engine, delta: number): void; +} + +export function has_preupdate(a: any): a is _preupdate { + return !!a._preupdate; +} + +export interface OnPreUpdate { + onPreUpdate(engine: Engine, delta: number): void; +} + +export function hasOnPreUpdate(a: any): a is OnPreUpdate { + return !!a.onPreUpdate; +} + +// eslint-disable-next-line @typescript-eslint/class-name-casing +export interface _postupdate { + _postupdate(engine: Engine, delta: number): void; +} + +export function has_postupdate(a: any): a is _postupdate { + return !!a.onPostUpdate; +} + +export interface OnPostUpdate { + onPostUpdate(engine: Engine, delta: number): void; +} + +export function hasOnPostUpdate(a: any): a is OnPostUpdate { + return !!a.onPostUpdate; +} + export interface CanInitialize { /** * Overridable implementation @@ -11,9 +62,9 @@ export interface CanInitialize { /** * Event signatures */ - on(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; - once(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; - off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; + on(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; + once(eventName: Events.initialize, handler: (event: Events.InitializeEvent) => void): void; + off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; } export interface CanActivate { @@ -53,9 +104,9 @@ export interface CanUpdate { /** * Event signature */ - on(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent) => void): void; - once(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent) => void): void; - off(eventName: Events.preupdate, handler?: (event: Events.PreUpdateEvent) => void): void; + on(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent) => void): void; + once(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent) => void): void; + off(eventName: Events.preupdate, handler?: (event: Events.PreUpdateEvent) => void): void; /** * Overridable implementation @@ -65,12 +116,12 @@ export interface CanUpdate { /** * Event signatures */ - on(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent) => void): void; - once(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent) => void): void; - off(eventName: Events.postupdate, handler?: (event: Events.PostUpdateEvent) => void): void; + on(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent) => void): void; + once(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent) => void): void; + off(eventName: Events.postupdate, handler?: (event: Events.PostUpdateEvent) => void): void; } -export interface CanDraw { +export interface OnPreDraw { /** * Overridable implementation */ @@ -82,7 +133,9 @@ export interface CanDraw { on(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; once(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; off(eventName: Events.predraw, handler?: (event: Events.PreDrawEvent) => void): void; +} +export interface OnPostDraw { /** * Overridable implementation */ @@ -96,6 +149,25 @@ export interface CanDraw { off(eventName: Events.postdraw, handler?: (event: Events.PostDrawEvent) => void): void; } +export interface CanDraw extends OnPreDraw, OnPostDraw { + on(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; + on(eventName: Events.postdraw, handler: (event: Events.PostDrawEvent) => void): void; + + once(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; + once(eventName: Events.postdraw, handler: (event: Events.PostDrawEvent) => void): void; + + off(eventName: Events.predraw, handler?: (event: Events.PreDrawEvent) => void): void; + off(eventName: Events.postdraw, handler?: (event: Events.PostDrawEvent) => void): void; +} + +export function hasPreDraw(a: any): a is OnPreDraw { + return !!a.onPreDraw; +} + +export function hasPostDraw(a: any): a is OnPostDraw { + return !!a.onPostDraw; +} + export interface CanBeKilled { /** * Overridable implementation diff --git a/src/engine/Scene.ts b/src/engine/Scene.ts index fa7665ba8..11cd4c11a 100644 --- a/src/engine/Scene.ts +++ b/src/engine/Scene.ts @@ -28,6 +28,10 @@ import * as Events from './Events'; import * as ActorUtils from './Util/Actors'; import { Trigger } from './Trigger'; import { Body } from './Collision/Body'; +import { QueryManager } from './EntityComponentSystem/QueryManager'; +import { EntityManager } from './EntityComponentSystem/EntityManager'; +import { SystemManager } from './EntityComponentSystem/SystemManager'; +import { SystemType } from './EntityComponentSystem/System'; /** * [[Actor|Actors]] are composed together into groupings called Scenes in * Excalibur. The metaphor models the same idea behind real world @@ -48,6 +52,10 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact */ public actors: Actor[] = []; + public queryManager: QueryManager = new QueryManager(this); + public entityManager: EntityManager = new EntityManager(this); + public systemManager: SystemManager = new SystemManager(this); + /** * Physics bodies in the current scene */ @@ -95,11 +103,11 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact } } - public on(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; + public on(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; public on(eventName: Events.activate, handler: (event: ActivateEvent) => void): void; public on(eventName: Events.deactivate, handler: (event: DeactivateEvent) => void): void; - public on(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; - public on(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; + public on(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; + public on(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; public on(eventName: Events.predraw, handler: (event: PreDrawEvent) => void): void; public on(eventName: Events.postdraw, handler: (event: PostDrawEvent) => void): void; public on(eventName: Events.predebugdraw, handler: (event: PreDebugDrawEvent) => void): void; @@ -109,11 +117,11 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact super.on(eventName, handler); } - public once(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; + public once(eventName: Events.initialize, handler: (event: InitializeEvent) => void): void; public once(eventName: Events.activate, handler: (event: ActivateEvent) => void): void; public once(eventName: Events.deactivate, handler: (event: DeactivateEvent) => void): void; - public once(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; - public once(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; + public once(eventName: Events.preupdate, handler: (event: PreUpdateEvent) => void): void; + public once(eventName: Events.postupdate, handler: (event: PostUpdateEvent) => void): void; public once(eventName: Events.predraw, handler: (event: PreDrawEvent) => void): void; public once(eventName: Events.postdraw, handler: (event: PostDrawEvent) => void): void; public once(eventName: Events.predebugdraw, handler: (event: PreDebugDrawEvent) => void): void; @@ -123,11 +131,11 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact super.once(eventName, handler); } - public off(eventName: Events.initialize, handler?: (event: InitializeEvent) => void): void; + public off(eventName: Events.initialize, handler?: (event: InitializeEvent) => void): void; public off(eventName: Events.activate, handler?: (event: ActivateEvent) => void): void; public off(eventName: Events.deactivate, handler?: (event: DeactivateEvent) => void): void; - public off(eventName: Events.preupdate, handler?: (event: PreUpdateEvent) => void): void; - public off(eventName: Events.postupdate, handler?: (event: PostUpdateEvent) => void): void; + public off(eventName: Events.preupdate, handler?: (event: PreUpdateEvent) => void): void; + public off(eventName: Events.postupdate, handler?: (event: PostUpdateEvent) => void): void; public off(eventName: Events.predraw, handler?: (event: PreDrawEvent) => void): void; public off(eventName: Events.postdraw, handler?: (event: PostDrawEvent) => void): void; public off(eventName: Events.predebugdraw, handler?: (event: PreDebugDrawEvent) => void): void; @@ -314,6 +322,9 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact */ public update(engine: Engine, delta: number) { this._preupdate(engine, delta); + this.systemManager.updateSystems(SystemType.Update, engine, delta); + this.entityManager.processRemovals(); + if (this.camera) { this.camera.update(engine, delta); } @@ -413,10 +424,11 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact public draw(ctx: CanvasRenderingContext2D, delta: number) { this._predraw(ctx, delta); ctx.save(); - if (this.camera) { this.camera.draw(ctx); } + this.systemManager.updateSystems(SystemType.Draw, this._engine, delta); + this.entityManager.processRemovals(); let i: number, len: number; diff --git a/src/engine/TileMap.ts b/src/engine/TileMap.ts index 7f4b01e45..324979194 100644 --- a/src/engine/TileMap.ts +++ b/src/engine/TileMap.ts @@ -29,8 +29,8 @@ export class TileMapImpl extends Class { public rows: number; public cols: number; - public on(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent) => void): void; - public on(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent) => void): void; + public on(eventName: Events.preupdate, handler: (event: Events.PreUpdateEvent) => void): void; + public on(eventName: Events.postupdate, handler: (event: Events.PostUpdateEvent) => void): void; public on(eventName: Events.predraw, handler: (event: Events.PreDrawEvent) => void): void; public on(eventName: Events.postdraw, handler: (event: Events.PostDrawEvent) => void): void; public on(eventName: string, handler: (event: Events.GameEvent) => void): void; @@ -149,7 +149,16 @@ export class TileMapImpl extends Class { return null; } + public onPreUpdate(_engine: Engine, _delta: number) { + // Override me + } + + public onPostUpdate(_engine: Engine, _delta: number) { + // Override me + } + public update(engine: Engine, delta: number) { + this.onPreUpdate(engine, delta); this.emit('preupdate', new Events.PreUpdateEvent(engine, delta, this)); const worldCoordsUpperLeft = engine.screenToWorldCoordinates(new Vector(0, 0)); @@ -160,6 +169,7 @@ export class TileMapImpl extends Class { this._onScreenXEnd = Math.max(Math.floor((worldCoordsLowerRight.x - this.x) / this.cellWidth) + 2, 0); this._onScreenYEnd = Math.max(Math.floor((worldCoordsLowerRight.y - this.y) / this.cellHeight) + 2, 0); + this.onPostUpdate(engine, delta); this.emit('postupdate', new Events.PostUpdateEvent(engine, delta, this)); } diff --git a/src/engine/Util/Observable.ts b/src/engine/Util/Observable.ts new file mode 100644 index 000000000..f5ea38d6a --- /dev/null +++ b/src/engine/Util/Observable.ts @@ -0,0 +1,29 @@ +export interface Message { + type: string; + data: T; +} + +export interface Observer { + notify(message: T): void; +} + +export type MaybeObserver = Partial>; + +export class Observable { + public observers: Observer[] = []; + + register(observer: Observer) { + this.observers.push(observer); + } + + unregister(observer: Observer) { + const i = this.observers.indexOf(observer); + if (i !== -1) { + this.observers.splice(i, 1); + } + } + + notifyAll(message: T) { + this.observers.forEach((o) => o.notify(message)); + } +} diff --git a/src/engine/Util/SortedList.ts b/src/engine/Util/SortedList.ts index 8f591a7f0..91a2f30e6 100644 --- a/src/engine/Util/SortedList.ts +++ b/src/engine/Util/SortedList.ts @@ -2,27 +2,27 @@ * A sorted list implementation. NOTE: this implementation is not self-balancing */ export class SortedList { - private _getComparable: Function; - private _root: BinaryTreeNode; + private _getComparable: (item: T) => number; + private _root: BinaryTreeNode; - constructor(getComparable: () => any) { + constructor(getComparable: (item: T) => number) { this._getComparable = getComparable; } - public find(element: any): boolean { + public find(element: T): boolean { return this._find(this._root, element); } - private _find(node: BinaryTreeNode, element: any): boolean { + private _find(node: BinaryTreeNode, element: any): boolean { if (node == null) { return false; - } else if (this._getComparable.call(element) === node.getKey()) { + } else if (this._getComparable(element) === node.getKey()) { if (node.getData().indexOf(element) > -1) { return true; } else { return false; } - } else if (this._getComparable.call(element) < node.getKey()) { + } else if (this._getComparable(element) < node.getKey()) { return this._find(node.getLeft(), element); } else { return this._find(node.getRight(), element); @@ -34,7 +34,7 @@ export class SortedList { return this._get(this._root, key); } - private _get(node: BinaryTreeNode, key: number): any[] { + private _get(node: BinaryTreeNode, key: number): any[] { if (node == null) { return []; } else if (key === node.getKey()) { @@ -46,34 +46,34 @@ export class SortedList { } } - public add(element: any): boolean { + public add(element: T): boolean { if (this._root == null) { - this._root = new BinaryTreeNode(this._getComparable.call(element), [element], null, null); + this._root = new BinaryTreeNode(this._getComparable(element), [element], null, null); return true; } else { return this._insert(this._root, element); } } - private _insert(node: BinaryTreeNode, element: any): boolean { + private _insert(node: BinaryTreeNode, element: T): boolean { if (node != null) { - if (this._getComparable.call(element) === node.getKey()) { + if (this._getComparable(element) === node.getKey()) { if (node.getData().indexOf(element) > -1) { return false; // the element we're trying to insert already exists } else { node.getData().push(element); return true; } - } else if (this._getComparable.call(element) < node.getKey()) { + } else if (this._getComparable(element) < node.getKey()) { if (node.getLeft() == null) { - node.setLeft(new BinaryTreeNode(this._getComparable.call(element), [element], null, null)); + node.setLeft(new BinaryTreeNode(this._getComparable.call(element, element), [element], null, null)); return true; } else { return this._insert(node.getLeft(), element); } } else { if (node.getRight() == null) { - node.setRight(new BinaryTreeNode(this._getComparable.call(element), [element], null, null)); + node.setRight(new BinaryTreeNode(this._getComparable.call(element, element), [element], null, null)); return true; } else { return this._insert(node.getRight(), element); @@ -83,14 +83,14 @@ export class SortedList { return false; } - public removeByComparable(element: any): void { + public removeByComparable(element: T): void { this._root = this._remove(this._root, element); } - private _remove(node: BinaryTreeNode, element: any): BinaryTreeNode { + private _remove(node: BinaryTreeNode, element: T): BinaryTreeNode { if (node == null) { return null; - } else if (this._getComparable.call(element) === node.getKey()) { + } else if (this._getComparable(element) === node.getKey()) { const elementIndex = node.getData().indexOf(element); // if the node contains the element, remove the element if (elementIndex > -1) { @@ -116,7 +116,7 @@ export class SortedList { return node; } } - } else if (this._getComparable.call(element) < node.getKey()) { + } else if (this._getComparable(element) < node.getKey()) { node.setLeft(this._remove(node.getLeft(), element)); return node; } else { @@ -127,7 +127,7 @@ export class SortedList { } // called once we have successfully removed the element we wanted, recursively corrects the part of the tree below the removed node - private _cleanup(node: BinaryTreeNode, element: BinaryTreeNode): BinaryTreeNode { + private _cleanup(node: BinaryTreeNode, element: BinaryTreeNode): BinaryTreeNode { const comparable = element.getKey(); if (node == null) { return null; @@ -147,7 +147,7 @@ export class SortedList { node.setRight(this._cleanup(node.getRight(), temp)); return node; - } else if (this._getComparable.call(element) < node.getKey()) { + } else if (element.getKey() < node.getKey()) { node.setLeft(this._cleanup(node.getLeft(), element)); return node; } else { @@ -156,7 +156,7 @@ export class SortedList { } } - private _findMinNode(node: BinaryTreeNode): BinaryTreeNode { + private _findMinNode(node: BinaryTreeNode): BinaryTreeNode { let current = node; while (current.getLeft() != null) { current = current.getLeft(); @@ -165,15 +165,15 @@ export class SortedList { } public list(): Array { - const results = new Array(); + const results = new Array(); this._list(this._root, results); return results; } - private _list(treeNode: BinaryTreeNode, results: Array): void { + private _list(treeNode: BinaryTreeNode, results: Array): void { if (treeNode != null) { this._list(treeNode.getLeft(), results); - treeNode.getData().forEach(function(element) { + treeNode.getData().forEach((element) => { results.push(element); }); this._list(treeNode.getRight(), results); @@ -184,13 +184,13 @@ export class SortedList { /** * A tree node part of [[SortedList]] */ -export class BinaryTreeNode { +export class BinaryTreeNode { private _key: number; - private _data: Array; - private _left: BinaryTreeNode; - private _right: BinaryTreeNode; + private _data: Array; + private _left: BinaryTreeNode; + private _right: BinaryTreeNode; - constructor(key: number, data: Array, left: BinaryTreeNode, right: BinaryTreeNode) { + constructor(key: number, data: Array, left: BinaryTreeNode, right: BinaryTreeNode) { this._key = key; this._data = data; this._left = left; @@ -205,27 +205,27 @@ export class BinaryTreeNode { this._key = key; } - public getData(): Array { + public getData(): T[] { return this._data; } - public setData(data: any) { + public setData(data: T[]) { this._data = data; } - public getLeft(): BinaryTreeNode { + public getLeft(): BinaryTreeNode { return this._left; } - public setLeft(left: BinaryTreeNode) { + public setLeft(left: BinaryTreeNode) { this._left = left; } - public getRight(): BinaryTreeNode { + public getRight(): BinaryTreeNode { return this._right; } - public setRight(right: BinaryTreeNode) { + public setRight(right: BinaryTreeNode) { this._right = right; } } diff --git a/src/engine/index.ts b/src/engine/index.ts index c19af03bd..cfa29dadf 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -40,6 +40,8 @@ export * from './Math/Index'; export * from './PostProcessing/Index'; export * from './Resources/Index'; +export * from './EntityComponentSystem/index'; + // ex.Events namespace import * as events from './Events'; export { events as Events }; diff --git a/src/engine/tsconfig.json b/src/engine/tsconfig.json index c9d400601..6b7e70bd3 100644 --- a/src/engine/tsconfig.json +++ b/src/engine/tsconfig.json @@ -17,6 +17,6 @@ "noUnusedLocals": true, "noUnusedParameters": true, "allowUnreachableCode": false, - "lib": ["dom", "es5", "es2015.promise", "es2015.collection", "es2015.iterable"] + "lib": ["dom", "es5", "es2015.collection", "es2015.iterable", "es2015.promise", "es2018.promise", "es2015.proxy"] } } diff --git a/src/spec/EntityManagerSpec.ts b/src/spec/EntityManagerSpec.ts new file mode 100644 index 000000000..863d00c0c --- /dev/null +++ b/src/spec/EntityManagerSpec.ts @@ -0,0 +1,75 @@ +import * as ex from '@excalibur'; + +class FakeComponent extends ex.Component { + constructor(public type: string) { + super(); + } +} + +describe('An EntityManager', () => { + it('exists', () => { + expect(ex.EntityManager).toBeDefined(); + }); + + it('can be created', () => { + const entityManager = new ex.EntityManager(new ex.Scene(null)); + expect(entityManager).not.toBeNull(); + }); + + it('can have entities added and removed from it', () => { + const entityManager = new ex.EntityManager(new ex.Scene(null)); + const entity = new ex.Entity(); + expect(entityManager.entities).toEqual([]); + + entityManager.addEntity(entity); + + expect(entityManager.entities).toEqual([entity]); + expect(entityManager.getById(entity.id)).toBe(entity); + + // Remove by entity + entityManager.removeEntity(entity); + + expect(entityManager.entities).toEqual([]); + expect(entityManager.getById(entity.id)).toBeUndefined(); + + // Remove by id + entityManager.addEntity(entity); + expect(entityManager.entities).toEqual([entity]); + entityManager.removeEntity(entity.id); + expect(entityManager.entities).toEqual([]); + }); + + it('will be notified when entity components are added', (done) => { + const entityManager = new ex.EntityManager(new ex.Scene(null)); + const entity = new ex.Entity(); + const componentA = new FakeComponent('A'); + entityManager.addEntity(entity); + + entityManager.notify = (message) => { + expect(message.type).toBe('Component Added'); + expect(message.data.entity).toBe(entity); + expect(message.data.component).toBe(componentA); + done(); + }; + + entity.addComponent(componentA); + }); + + it('will be notified when entity components are removed', (done) => { + const entityManager = new ex.EntityManager(new ex.Scene(null)); + const entity = new ex.Entity(); + const componentA = new FakeComponent('A'); + entity.addComponent(componentA); + entityManager.addEntity(entity); + + entityManager.notify = (message) => { + expect(message.type).toBe('Component Removed'); + expect(message.data.entity).toBe(entity); + expect(message.data.component).toBe(componentA); + done(); + }; + + entity.removeComponent(componentA); + entity.processRemoval(); + }); +}); diff --git a/src/spec/EntitySpec.ts b/src/spec/EntitySpec.ts new file mode 100644 index 000000000..b4952705c --- /dev/null +++ b/src/spec/EntitySpec.ts @@ -0,0 +1,169 @@ +import * as ex from '@excalibur'; +import { TagComponent } from '@excalibur'; + +class FakeComponent extends ex.Component { + constructor(public type: string) { + super(); + } +} + +describe('An entity', () => { + it('exists', () => { + expect(ex.Entity).toBeDefined(); + }); + + it('has a unique id', () => { + const entity1 = new ex.Entity(); + const entity2 = new ex.Entity(); + const entity3 = new ex.Entity(); + expect(entity1.id).not.toBe(entity2.id); + expect(entity1.id).not.toBe(entity3.id); + expect(entity2.id).not.toBe(entity3.id); + }); + + it('can be killed', () => { + const entity = new ex.Entity(); + expect(entity.isKilled()).toBe(false); + entity.kill(); + expect(entity.isKilled()).toBe(true); + }); + + it('can have types by component', () => { + const entity = new ex.Entity(); + const typeA = new FakeComponent('A'); + const typeB = new FakeComponent('B'); + expect(entity.types).toEqual([]); + + entity.addComponent(typeA); + expect(entity.types).toEqual(['A']); + entity.addComponent(typeB); + expect(entity.types).toEqual(['A', 'B']); + entity.removeComponent(typeA, true); + expect(entity.types).toEqual(['B']); + entity.removeComponent(typeB, true); + expect(entity.types).toEqual([]); + }); + + it('can have type from tag components', () => { + const entity = new ex.Entity(); + const isOffscreen = new TagComponent('offscreen'); + + expect(entity.types).toEqual([]); + entity.addComponent(isOffscreen); + + expect(entity.types).toEqual(['offscreen']); + }); + + it('can be observed for added changes', (done) => { + const entity = new ex.Entity(); + const typeA = new FakeComponent('A'); + entity.changes.register({ + notify: (change) => { + expect(change.type).toBe('Component Added'); + expect(change.data.entity).toBe(entity); + expect(change.data.component).toBe(typeA); + done(); + } + }); + entity.addComponent(typeA); + }); + + it('can be observed for removed changes', (done) => { + const entity = new ex.Entity(); + const typeA = new FakeComponent('A'); + + entity.addComponent(typeA); + entity.changes.register({ + notify: (change) => { + expect(change.type).toBe('Component Removed'); + expect(change.data.entity).toBe(entity); + expect(change.data.component).toBe(typeA); + done(); + } + }); + entity.removeComponent(typeA); + entity.processRemoval(); + }); + + it('can be cloned', () => { + const entity = new ex.Entity(); + const typeA = new FakeComponent('A'); + const typeB = new FakeComponent('B'); + entity.addComponent(typeA); + entity.addComponent(typeB); + + const clone = entity.clone(); + expect(clone).not.toBe(entity); + expect(clone.id).not.toBe(entity.id); + expect(clone.components.A).not.toBe(entity.components.A); + expect(clone.components.B).not.toBe(entity.components.B); + expect(clone.components.A.type).toBe(entity.components.A.type); + expect(clone.components.B.type).toBe(entity.components.B.type); + }); + + it('can be checked if it has a component', () => { + const entity = new ex.Entity(); + const typeA = new FakeComponent('A'); + const typeB = new FakeComponent('B'); + entity.addComponent(typeA); + entity.addComponent(typeB); + + expect(entity.has('A')).toBe(true); + expect(entity.has('B')).toBe(true); + expect(entity.has('C')).toBe(false); + }); + + it('has an overridable initialize lifecycle handler', (done) => { + const entity = new ex.Entity(); + entity.onInitialize = () => { + done(); + }; + + entity._initialize(null); + }); + + it('has an event initialize handler', (done) => { + const entity = new ex.Entity(); + entity.on('initialize', () => { + done(); + }); + + entity._initialize(null); + }); + + it('has an overridable preupdate lifecycle handler', (done) => { + const entity = new ex.Entity(); + entity.onPreUpdate = () => { + done(); + }; + + entity._preupdate(null, 1); + }); + + it('has an event preupdate handler', (done) => { + const entity = new ex.Entity(); + entity.on('preupdate', () => { + done(); + }); + + entity._preupdate(null, 1); + }); + + it('has an overridable postupdate lifecycle handler', (done) => { + const entity = new ex.Entity(); + entity.onPostUpdate = () => { + done(); + }; + + entity._postupdate(null, 1); + }); + + it('has an event postupdate handler', (done) => { + const entity = new ex.Entity(); + entity.on('postupdate', () => { + done(); + }); + + entity._postupdate(null, 1); + }); +}); diff --git a/src/spec/QueryManagerSpec.ts b/src/spec/QueryManagerSpec.ts new file mode 100644 index 000000000..4e97ee37c --- /dev/null +++ b/src/spec/QueryManagerSpec.ts @@ -0,0 +1,150 @@ +import * as ex from '@excalibur'; + +class FakeComponent extends ex.Component { + constructor(public type: T) { + super(); + } +} + +describe('A QueryManager', () => { + it('exists', () => { + expect(ex.QueryManager).toBeDefined(); + }); + + it('can be created', () => { + const queryManager = new ex.QueryManager(new ex.Scene(null)); + expect(queryManager).not.toBeNull(); + }); + + it('can create queries for entities', () => { + const scene = new ex.Scene(null); + const entity1 = new ex.Entity | FakeComponent<'B'>>(); + entity1.addComponent(new FakeComponent('A')); + entity1.addComponent(new FakeComponent('B')); + + const entity2 = new ex.Entity>(); + entity2.addComponent(new FakeComponent('A')); + + scene.entityManager.addEntity(entity1); + scene.entityManager.addEntity(entity2); + + // Query for all entities that have type A components + const queryA = scene.queryManager.createQuery>(['A']); + // Query for all entities that have type A & B components + const queryAB = scene.queryManager.createQuery | FakeComponent<'B'>>(['A', 'B']); + + expect(queryA.entities).toEqual([entity1, entity2]); + expect(queryAB.entities).toEqual([entity1]); + + // Queries update if component change + entity2.addComponent(new FakeComponent('B')); + expect(queryAB.entities).toEqual([entity1, entity2 as ex.Entity | FakeComponent<'B'>>]); + + // Queries update if components change + entity2.removeComponent('B', true); + expect(queryAB.entities).toEqual([entity1]); + + // Queries are deferred by default, so queries will update after removals + entity1.removeComponent('B'); + expect(queryAB.entities).toEqual([entity1]); + entity1.processRemoval(); + expect(queryAB.entities).toEqual([]); + }); + + it('can remove queries', () => { + const scene = new ex.Scene(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponent('A')); + entity1.addComponent(new FakeComponent('B')); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponent('A')); + entity2.addComponent(new FakeComponent('B')); + + scene.entityManager.addEntity(entity1); + scene.entityManager.addEntity(entity2); + + // Query for all entities that have type A components + const queryA = scene.queryManager.createQuery(['A', 'B']); + + queryA.register({ + notify: () => {} // eslint-disable-line @typescript-eslint/no-empty-function + }); + + expect(scene.queryManager.getQuery(['A', 'B'])).toBe(queryA); + + // Query will not be removed if there are observers + scene.queryManager.maybeRemoveQuery(queryA); + expect(scene.queryManager.getQuery(['A', 'B'])).toBe(queryA); + + // Query will be removed if no observers + queryA.clear(); + scene.queryManager.maybeRemoveQuery(queryA); + expect(scene.queryManager.getQuery(['A', 'B'])).toBe(null); + }); + + it('can add entities to queries', () => { + const scene = new ex.Scene(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponent('A')); + entity1.addComponent(new FakeComponent('B')); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponent('A')); + entity2.addComponent(new FakeComponent('B')); + + const queryAB = scene.queryManager.createQuery(['A', 'B']); + expect(queryAB.entities).toEqual([]); + + scene.queryManager.addEntity(entity1); + expect(queryAB.entities).toEqual([entity1]); + + scene.queryManager.addEntity(entity2); + expect(queryAB.entities).toEqual([entity1, entity2]); + }); + + it('can remove entities from queries', () => { + const scene = new ex.Scene(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponent('A')); + entity1.addComponent(new FakeComponent('B')); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponent('A')); + entity2.addComponent(new FakeComponent('B')); + + const queryAB = scene.queryManager.createQuery(['A', 'B']); + scene.queryManager.addEntity(entity1); + scene.queryManager.addEntity(entity2); + expect(queryAB.entities).toEqual([entity1, entity2]); + + scene.queryManager.removeEntity(entity1); + expect(queryAB.entities).toEqual([entity2]); + + scene.queryManager.removeEntity(entity2); + expect(queryAB.entities).toEqual([]); + }); + + it('can update queries when a component is removed', () => { + const scene = new ex.Scene(null); + const entity1 = new ex.Entity(); + entity1.addComponent(new FakeComponent('A')); + entity1.addComponent(new FakeComponent('B')); + + const entity2 = new ex.Entity(); + entity2.addComponent(new FakeComponent('A')); + entity2.addComponent(new FakeComponent('B')); + + const queryAB = scene.queryManager.createQuery(['A', 'B']); + scene.queryManager.addEntity(entity1); + scene.queryManager.addEntity(entity2); + + expect(queryAB.entities).toEqual([entity1, entity2]); + + const removed = entity1.components.A; + entity1.removeComponent('A'); + scene.queryManager.removeComponent(entity1, removed); + + expect(queryAB.entities).toEqual([entity2]); + }); +}); diff --git a/src/spec/QuerySpec.ts b/src/spec/QuerySpec.ts new file mode 100644 index 000000000..4f9944be3 --- /dev/null +++ b/src/spec/QuerySpec.ts @@ -0,0 +1,136 @@ +import * as ex from '@excalibur'; + +class FakeComponent extends ex.Component { + constructor(public type: T) { + super(); + } +} + +describe('A query', () => { + it('should exist', () => { + expect(ex.Query).toBeDefined(); + }); + + it('can be created', () => { + expect(new ex.Query(['A', 'B'])).not.toBeNull(); + }); + + it('has a key', () => { + const query = new ex.Query(['A', 'B']); + expect(query.key).toBe('A+B'); + }); + + it('can match with entities', () => { + const queryAB = new ex.Query(['A', 'B']); + const compA = new FakeComponent('A'); + const compB = new FakeComponent('B'); + const entity1 = new ex.Entity(); + entity1.addComponent(compA); + entity1.addComponent(compB); + + const entity2 = new ex.Entity(); + entity2.addComponent(compA); + + expect(queryAB.matches(entity1)).toBe(true, 'entity1 should match has both compontents A, B'); + expect(queryAB.matches(['A', 'B'])).toBe(true); + expect(queryAB.matches(entity2)).toBe(false, 'entity2 should not match, only has 1 component A'); + expect(queryAB.matches(['A'])).toBe(false); + }); + + it('can only add entities that match', () => { + const queryAB = new ex.Query(['A', 'B']); + const compA = new FakeComponent('A'); + const compB = new FakeComponent('B'); + const entity1 = new ex.Entity | FakeComponent<'B'>>(); + entity1.addComponent(compA); + entity1.addComponent(compB); + + const entity2 = new ex.Entity>(); + entity2.addComponent(compA); + + queryAB.addEntity(entity1); + expect(queryAB.entities).toEqual([entity1]); + + queryAB.addEntity(entity2); + expect(queryAB.entities).toEqual([entity1]); + }); + + it('can remove entities', () => { + const queryAB = new ex.Query(['A', 'B']); + const compA = new FakeComponent('A'); + const compB = new FakeComponent('B'); + const entity1 = new ex.Entity | FakeComponent<'B'>>(); + entity1.addComponent(compA); + entity1.addComponent(compB); + + const entity2 = new ex.Entity>(); + entity2.addComponent(compA); + + queryAB.addEntity(entity1); + expect(queryAB.entities).toEqual([entity1]); + + queryAB.removeEntity(entity2); + expect(queryAB.entities).toEqual([entity1]); + + queryAB.removeEntity(entity1); + expect(queryAB.entities).toEqual([]); + }); + + it('notifies obersvers of when something is added to the query', (done) => { + const queryAB = new ex.Query(['A', 'B']); + const compA = new FakeComponent('A'); + const compB = new FakeComponent('B'); + const entity1 = new ex.Entity | FakeComponent<'B'>>(); + entity1.addComponent(compA); + entity1.addComponent(compB); + + queryAB.register({ + notify: (message) => { + expect(message.type).toBe('Entity Added'); + expect(message.data).toBe(entity1); + done(); + } + }); + + queryAB.addEntity(entity1); + }); + + it('notifies obersvers of when something is added to the query', (done) => { + const queryAB = new ex.Query(['A', 'B']); + const compA = new FakeComponent('A'); + const compB = new FakeComponent('B'); + const entity1 = new ex.Entity | FakeComponent<'B'>>(); + entity1.addComponent(compA); + entity1.addComponent(compB); + queryAB.addEntity(entity1); + + queryAB.register({ + notify: (message) => { + expect(message.type).toBe('Entity Removed'); + expect(message.data).toBe(entity1); + done(); + } + }); + + queryAB.removeEntity(entity1); + }); + + it('can clear all observers and entities from the query', () => { + const queryAB = new ex.Query(['A', 'B']); + const compA = new FakeComponent('A'); + const compB = new FakeComponent('B'); + const entity1 = new ex.Entity | FakeComponent<'B'>>(); + entity1.addComponent(compA); + entity1.addComponent(compB); + + queryAB.addEntity(entity1); + queryAB.register({ + notify: () => {} // eslint-disable-line @typescript-eslint/no-empty-function + }); + expect(queryAB.entities).toEqual([entity1]); + expect(queryAB.observers.length).toBe(1); + queryAB.clear(); + expect(queryAB.entities).toEqual([]); + expect(queryAB.observers.length).toBe(0); + }); +}); diff --git a/src/spec/SortedListSpec.ts b/src/spec/SortedListSpec.ts index 1bc441803..c6b9350d7 100644 --- a/src/spec/SortedListSpec.ts +++ b/src/spec/SortedListSpec.ts @@ -5,7 +5,9 @@ describe('A SortedList', () => { let sortedList; beforeEach(() => { - sortedList = new ex.SortedList(Mocks.MockedElement.prototype.getTheKey); + sortedList = new ex.SortedList((e) => { + return e.getTheKey(); + }); }); it('should be loaded', () => { diff --git a/src/spec/SystemManagerSpec.ts b/src/spec/SystemManagerSpec.ts new file mode 100644 index 000000000..1afc2a9af --- /dev/null +++ b/src/spec/SystemManagerSpec.ts @@ -0,0 +1,132 @@ +import * as ex from '@excalibur'; +import { SystemType } from '@excalibur'; + +class FakeComponent extends ex.Component { + constructor(public type: T) { + super(); + } +} + +class FakeSystem extends ex.System { + constructor(public priority: number, public name: string, public types: string[], public systemType: ex.SystemType) { + super(); + } + update(entities: ex.Entity[], delta: number): void { + // fake + } +} + +describe('A SystemManager', () => { + it('exists', () => { + expect(ex.SystemManager).toBeDefined(); + }); + + it('can be created', () => { + const sm = new ex.SystemManager(null); + }); + + it('can add systems', () => { + const sm = new ex.Scene(null).systemManager; + + // Lower priority + const s3 = new FakeSystem(2, 'System3', ['C'], SystemType.Update); + sm.addSystem(s3); + // Systems of equal priority should preserve order + const s1 = new FakeSystem(1, 'System1', ['A'], SystemType.Update); + const s2 = new FakeSystem(1, 'System2', ['C', 'B'], SystemType.Update); + sm.addSystem(s1); + sm.addSystem(s2); + + expect(sm.systems).toEqual([s1, s2, s3]); + }); + + it('can remove systems', () => { + const sm = new ex.Scene(null).systemManager; + + // Lower priority + const s3 = new FakeSystem(2, 'System3', ['C'], SystemType.Update); + sm.addSystem(s3); + // Systems of equal priority should preserve order + const s1 = new FakeSystem(1, 'System1', ['A'], SystemType.Update); + const s2 = new FakeSystem(1, 'System2', ['C', 'B'], SystemType.Update); + sm.addSystem(s1); + sm.addSystem(s2); + + expect(sm.systems).toEqual([s1, s2, s3]); + sm.removeSystem(s3); + sm.removeSystem(s1); + expect(sm.systems).toEqual([s2]); + }); + + it('can update systems', () => { + const sm = new ex.Scene(null).systemManager; + const system = new FakeSystem(2, 'System3', ['C'], SystemType.Update); + system.preupdate = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function + system.postupdate = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function + spyOn(system, 'preupdate'); + spyOn(system, 'postupdate'); + spyOn(system, 'update'); + sm.addSystem(system); + sm.updateSystems(SystemType.Update, null, 10); + expect(system.preupdate).toHaveBeenCalledTimes(1); + expect(system.update).toHaveBeenCalledTimes(1); + expect(system.postupdate).toHaveBeenCalledTimes(1); + }); + + it('can update systems with the correct entities', () => { + const scene = new ex.Scene(null); + const sm = scene.systemManager; + const qm = scene.queryManager; + const em = scene.entityManager; + const system = new FakeSystem(2, 'System3', ['A', 'C'], SystemType.Update); + spyOn(system, 'update').and.callThrough(); + sm.addSystem(system); + + const e1 = new ex.Entity(); + e1.addComponent(new FakeComponent('A')); + e1.addComponent(new FakeComponent('C')); + + const e2 = new ex.Entity(); + e2.addComponent(new FakeComponent('B')); + + const e3 = new ex.Entity(); + e3.addComponent(new FakeComponent('C')); + e3.addComponent(new FakeComponent('A')); + + em.addEntity(e1); + em.addEntity(e2); + em.addEntity(e3); + + const query = qm.getQuery(['A', 'C']); + expect(query.entities).toEqual([e1, e3]); + + sm.updateSystems(SystemType.Update, null, 10); + + expect(system.update).toHaveBeenCalledWith([e1, e3], 10); + }); + + it('only updates system of the specified system type', () => { + const scene = new ex.Scene(null); + const sm = scene.systemManager; + const qm = scene.queryManager; + const em = scene.entityManager; + const system1 = new FakeSystem(2, 'System1', ['A', 'C'], SystemType.Update); + spyOn(system1, 'update').and.callThrough(); + sm.addSystem(system1); + const system2 = new FakeSystem(2, 'System1', ['A', 'C'], SystemType.Draw); + spyOn(system2, 'update').and.callThrough(); + sm.addSystem(system2); + + sm.updateSystems(SystemType.Draw, null, 10); + + expect(system1.update).not.toHaveBeenCalled(); + expect(system2.update).toHaveBeenCalledWith([], 10); + }); + + it('should throw on invalid system', () => { + const sm = new ex.Scene(null).systemManager; + expect(() => { + sm.addSystem(new FakeSystem(0, 'ErrorSystem', [], SystemType.Update)); + }).toThrow(new Error('Attempted to add a System without any types')); + }); +}); diff --git a/src/spec/tsconfig.json b/src/spec/tsconfig.json index 284b20095..c826f0684 100644 --- a/src/spec/tsconfig.json +++ b/src/spec/tsconfig.json @@ -8,7 +8,7 @@ "module": "es2015", "noImplicitAny": false, "noUnusedLocals": false, - "lib": ["dom", "es5", "es2015.collection", "es2015.iterable"], + "lib": ["dom", "es5", "es2015.collection", "es2015.iterable", "es2015.promise", "es2015.proxy"], "baseUrl": ".", "rootDir": "../", "paths": {