From 81495b276990b49f1d4523ab8450b7b866ad29a9 Mon Sep 17 00:00:00 2001 From: Justin Young <62815737+jyoung4242@users.noreply.github.com> Date: Wed, 18 Sep 2024 22:56:27 -0400 Subject: [PATCH] feat: Entity onAdd and onRemove event handlers and lifecycle (#3143) * first cut at onAdd and onRemove * updated transfer method * complete * added on('event') for add and remove * added test that doesn't work * fix test --------- Co-authored-by: Erik Onarheim --- package-lock.json | 1 + src/engine/Actor.ts | 2 + src/engine/EntityComponentSystem/Entity.ts | 67 +++++++++++++++++++++- src/engine/Events.ts | 34 ++++++++++- src/engine/Interfaces/LifecycleEvents.ts | 60 +++++++++++++++++++ src/engine/Scene.ts | 1 + src/spec/ActorSpec.ts | 23 ++++++++ 7 files changed, 183 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c054bb9e..f12d944d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6264,6 +6264,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", "dev": true, + "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } diff --git a/src/engine/Actor.ts b/src/engine/Actor.ts index 3826fd594..dbe46656a 100644 --- a/src/engine/Actor.ts +++ b/src/engine/Actor.ts @@ -984,8 +984,10 @@ export class Actor extends Entity implements Eventable, PointerEvents, CanInitia */ public update(engine: Engine, elapsedMs: number) { this._initialize(engine); + this._add(engine); this._preupdate(engine, elapsedMs); this._postupdate(engine, elapsedMs); + this._remove(engine); } /** diff --git a/src/engine/EntityComponentSystem/Entity.ts b/src/engine/EntityComponentSystem/Entity.ts index c95b622d1..604cfc2e9 100644 --- a/src/engine/EntityComponentSystem/Entity.ts +++ b/src/engine/EntityComponentSystem/Entity.ts @@ -1,9 +1,9 @@ import { Component, ComponentCtor, isComponentCtor } from './Component'; import { Observable, Message } from '../Util/Observable'; -import { OnInitialize, OnPreUpdate, OnPostUpdate } from '../Interfaces/LifecycleEvents'; +import { OnInitialize, OnPreUpdate, OnPostUpdate, OnAdd, OnRemove } from '../Interfaces/LifecycleEvents'; import { Engine } from '../Engine'; -import { InitializeEvent, PreUpdateEvent, PostUpdateEvent } from '../Events'; +import { InitializeEvent, PreUpdateEvent, PostUpdateEvent, AddEvent, RemoveEvent } from '../Events'; import { KillEvent } from '../Events'; import { EventEmitter, EventKey, Handler, Subscription } from '../EventEmitter'; import { Scene } from '../Scene'; @@ -54,12 +54,18 @@ export function isRemovedComponent(x: Message): x is RemovedCom */ export type EntityEvents = { initialize: InitializeEvent; + //@ts-ignore + add: AddEvent; + //@ts-ignore + remove: RemoveEvent; preupdate: PreUpdateEvent; postupdate: PostUpdateEvent; kill: KillEvent; }; export const EntityEvents = { + Add: 'add', + Remove: 'remove', Initialize: 'initialize', PreUpdate: 'preupdate', PostUpdate: 'postupdate', @@ -83,7 +89,7 @@ export interface EntityOptions { * entity.components.b; // Type ComponentB * ``` */ -export class Entity implements OnInitialize, OnPreUpdate, OnPostUpdate { +export class Entity implements OnInitialize, OnPreUpdate, OnPostUpdate, OnAdd, OnRemove { private static _ID = 0; /** * The unique identifier for the entity @@ -513,6 +519,7 @@ export class Entity implements OnIniti } private _isInitialized = false; + private _isAdded = false; /** * Gets whether the actor is Initialized @@ -521,6 +528,10 @@ export class Entity implements OnIniti return this._isInitialized; } + public get isAdded(): boolean { + return this._isAdded; + } + /** * Initializes this entity, meant to be called by the Scene before first update not by users of Excalibur. * @@ -535,6 +546,34 @@ export class Entity implements OnIniti } } + /** + * Adds this Actor, meant to be called by the Scene when Actor is added. + * + * It is not recommended that internal excalibur methods be overridden, do so at your own risk. + * @internal + */ + public _add(engine: Engine) { + if (!this.isAdded && this.active) { + this.onAdd(engine); + this.events.emit('add', new AddEvent(engine, this)); + this._isAdded = true; + } + } + + /** + * Removes Actor, meant to be called by the Scene when Actor is added. + * + * It is not recommended that internal excalibur methods be overridden, do so at your own risk. + * @internal + */ + public _remove(engine: Engine) { + if (this.isAdded && !this.active) { + this.onRemove(engine); + this.events.emit('remove', new RemoveEvent(engine, this)); + this._isAdded = false; + } + } + /** * It is not recommended that internal excalibur methods be overridden, do so at your own risk. * @@ -567,6 +606,26 @@ export class Entity implements OnIniti // Override me } + /** + * `onAdd` is called when Actor is added to scene. This method is meant to be + * overridden. + * + * Synonymous with the event handler `.on('add', (evt) => {...})` + */ + public onAdd(engine: Engine): void { + // Override me + } + + /** + * `onRemove` is called when Actor is added to scene. This method is meant to be + * overridden. + * + * Synonymous with the event handler `.on('remove', (evt) => {...})` + */ + public onRemove(engine: Engine): void { + // Override me + } + /** * Safe to override onPreUpdate lifecycle event handler. Synonymous with `.on('preupdate', (evt) =>{...})` * @@ -594,11 +653,13 @@ export class Entity implements OnIniti */ public update(engine: Engine, elapsedMs: number): void { this._initialize(engine); + this._add(engine); this._preupdate(engine, elapsedMs); for (const child of this.children) { child.update(engine, elapsedMs); } this._postupdate(engine, elapsedMs); + this._remove(engine); } public emit>(eventName: TEventName, event: EntityEvents[TEventName]): void; diff --git a/src/engine/Events.ts b/src/engine/Events.ts index c6d69a13d..543ac4cf7 100644 --- a/src/engine/Events.ts +++ b/src/engine/Events.ts @@ -9,7 +9,7 @@ import { Side } from './Collision/Side'; import { CollisionContact } from './Collision/Detection/CollisionContact'; import { Collider } from './Collision/Colliders/Collider'; import { Entity } from './EntityComponentSystem/Entity'; -import { OnInitialize, OnPreUpdate, OnPostUpdate, SceneActivationContext } from './Interfaces/LifecycleEvents'; +import { OnInitialize, OnPreUpdate, OnPostUpdate, SceneActivationContext, OnAdd, OnRemove } from './Interfaces/LifecycleEvents'; import { BodyComponent } from './Collision/BodyComponent'; import { ExcaliburGraphicsContext } from './Graphics'; import { Axes, Buttons, Gamepad } from './Input/Gamepad'; @@ -84,7 +84,10 @@ export enum EventTypes { PointerDragMove = 'pointerdragmove', ActionStart = 'actionstart', - ActionComplete = 'actioncomplete' + ActionComplete = 'actioncomplete', + + Add = 'add', + Remove = 'remove' } /* istanbul ignore next */ @@ -159,6 +162,9 @@ export type pointerdragenter = 'pointerdragenter'; export type pointerdragleave = 'pointerdragleave'; export type pointerdragmove = 'pointerdragmove'; +export type add = 'add'; +export type remove = 'remove'; + /** * Base event type in Excalibur that all other event types derive from. Not all event types are thrown on all Excalibur game objects, * some events are unique to a type, others are not. @@ -706,3 +712,27 @@ export class ActionCompleteEvent extends GameEvent { super(); } } + +/** + * Event thrown on an [[Actor]] when an Actor added to scene. + */ +export class AddEvent extends GameEvent { + constructor( + public engine: Engine, + public target: T + ) { + super(); + } +} + +/** + * Event thrown on an [[Actor]] when an Actor removed from scene. + */ +export class RemoveEvent extends GameEvent { + constructor( + public engine: Engine, + public target: T + ) { + super(); + } +} diff --git a/src/engine/Interfaces/LifecycleEvents.ts b/src/engine/Interfaces/LifecycleEvents.ts index 22a446c76..f46c99c71 100644 --- a/src/engine/Interfaces/LifecycleEvents.ts +++ b/src/engine/Interfaces/LifecycleEvents.ts @@ -7,6 +7,14 @@ import { ExcaliburGraphicsContext } from '../Graphics'; export interface _initialize { _initialize(engine: Engine): void; } +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface _add { + onAdd(scene: Scene): void; +} +// eslint-disable-next-line @typescript-eslint/naming-convention +export interface _remove { + onRemove(engine: Engine): void; +} /** * Type guard checking for internal initialize method @@ -17,6 +25,20 @@ export function has_initialize(a: any): a is _initialize { return !!a._initialize; } +/** + * + */ +export function has_add(a: any): a is _add { + return !!a.onAdd; +} + +/** + * + */ +export function has_remove(a: any): a is _remove { + return !!a.onRemove; +} + export interface OnInitialize { onInitialize(engine: Engine): void; } @@ -88,6 +110,44 @@ export interface CanInitialize { off(eventName: Events.initialize, handler?: (event: Events.InitializeEvent) => void): void; } +export interface OnAdd { + onAdd(engine: Engine): void; +} + +/** + * + */ +export function hasOnAdd(a: any): a is OnAdd { + return !!a.onAdd; +} + +export interface CanAdd { + onAdd(engine: Engine): void; + + on(eventName: Events.add, handler: (event: Events.AddEvent) => void): void; + once(eventName: Events.add, handler: (event: Events.AddEvent) => void): void; + off(eventName: Events.add, handler?: (event: Events.AddEvent) => void): void; +} + +export interface OnRemove { + onRemove(engine: Engine): void; +} + +/** + * + */ +export function hasOnRemove(a: any): a is OnRemove { + return !!a.onRemove; +} + +export interface CanRemove { + onRemove(engine: Engine): void; + + on(eventName: Events.remove, handler: (event: Events.RemoveEvent) => void): void; + once(eventName: Events.remove, handler: (event: Events.RemoveEvent) => void): void; + off(eventName: Events.remove, handler?: (event: Events.RemoveEvent) => void): void; +} + export interface SceneActivationContext { data?: TData; previousScene: Scene; diff --git a/src/engine/Scene.ts b/src/engine/Scene.ts index 5b90e5668..8102ff4b3 100644 --- a/src/engine/Scene.ts +++ b/src/engine/Scene.ts @@ -613,6 +613,7 @@ export class Scene implements CanInitialize, CanActiv scene = entity.scene; entity.scene.removeTimer(entity); } + scene?.emit('entityremoved', { target: entity } as any); this.add(entity); } diff --git a/src/spec/ActorSpec.ts b/src/spec/ActorSpec.ts index 5f36742a7..6e1bb4dc4 100644 --- a/src/spec/ActorSpec.ts +++ b/src/spec/ActorSpec.ts @@ -1439,5 +1439,28 @@ describe('A game actor', () => { expect(actor._postkill).toHaveBeenCalledTimes(1); expect(actor.onPostKill).toHaveBeenCalledTimes(1); }); + + it('can run onAdd and onRemove', () => { + actor.onRemove = (engine: ex.Engine) => { + expect(engine).not.toBe(null); + }; + + actor.onAdd = (engine: ex.Engine) => { + expect(engine).not.toBe(null); + }; + + spyOn(actor, 'onAdd').and.callThrough(); + spyOn(actor, 'onRemove').and.callThrough(); + engine.add(actor); + engine.currentScene.update(engine, 100); + engine.remove(actor); + engine.currentScene.update(engine, 100); + expect(actor.onAdd).toHaveBeenCalledTimes(1); + expect(actor.onRemove).toHaveBeenCalledTimes(1); + engine.add(actor); + engine.currentScene.update(engine, 100); + expect(actor.onAdd).toHaveBeenCalledTimes(2); + expect(actor.onRemove).toHaveBeenCalledTimes(1); + }); }); });