import { isDevEnvironment } from "../engine/debug/index.js"; import { InstantiateContext } from "../engine/engine_gameobject.js"; import type { IComponent, IEventList } from "../engine/engine_types.js"; const argumentsBuffer = new Array(); export class CallInfo { /** * When the CallInfo is enabled it will be invoked when the EventList is invoked */ enabled: boolean = true; /** * The target object to invoke the method on OR the function to invoke */ target: Object | Function; methodName: string | null; /** * The arguments to invoke this method with */ arguments?: Array; get canClone() { return this.target instanceof Object; } constructor(target: Function); constructor(target: Object, methodName: string | null, args?: Array, enabled?: boolean); constructor(target: Object | Function, methodName?: string | null, args?: Array, enabled?: boolean) { this.target = target; this.methodName = methodName || null; this.arguments = args; if (enabled != undefined) this.enabled = enabled; } invoke(...args: any) { if (this.enabled === false) return; // CallInfo can just contain a function if (typeof this.target === "function") { if (this.arguments) { argumentsBuffer.length = 0; // we pass the custom arguments first and then the event arguments (if any) // this is so that invoke("myEvent") will take precedence over the event arguments // see https://linear.app/needle/issue/NE-5507 if (args !== undefined && args.length > 0) argumentsBuffer.push(...args); argumentsBuffer.push(...this.arguments); this.target(...this.arguments); argumentsBuffer.length = 0; } else { this.target(...args); } } else if (this.methodName != null) { const method = this.target[this.methodName]; // If the target is callable if (typeof method === "function") { if (this.arguments) { argumentsBuffer.length = 0; // we pass the custom arguments first and then the event arguments (if any) // this is so that invoke("myEvent") will take precedence over the event arguments // see https://linear.app/needle/issue/NE-5507 if (args !== undefined && args.length > 0) argumentsBuffer.push(...args); argumentsBuffer.push(...this.arguments); method.call(this.target, ...argumentsBuffer); argumentsBuffer.length = 0; } else { method.call(this.target, ...args); } } // If the target is a property else { if (this.arguments) { this.target[this.methodName] = this.arguments[0] || args[0]; } else { this.target[this.methodName] = args[0]; } } } } } const isUpperCase = (string) => /^[A-Z]*$/.test(string); export class EventListEvent extends Event { //implements ArrayLike { args?: TArgs; } /** * The EventList is a class that can be used to create a list of event listeners that can be invoked */ export class EventList implements IEventList { /** checked during instantiate to create a new instance */ readonly isEventList = true; /** * @internal Used by the Needle Engine instantiate call to remap the event listeners to the new instance */ __internalOnInstantiate(ctx: InstantiateContext) { const newMethods = new Array(); for (let i = 0; i < this.methods.length; i++) { const method = this.methods[i]; if (method.target instanceof Function) { // can not clone a function } else { const target = method.target as { uuid?: string }; let key = target?.uuid; if ((target as IComponent)) { key = (target as IComponent).guid; } if (key) { const newTarget = ctx[key]; if (newTarget) { // remap the arguments to the new instance (e.g. if an object is passed as an argument to the event list and this object has been cloned we want to remap it to the clone) const newArguments = method.arguments?.map(arg => { if (arg instanceof Object && arg.uuid) { return ctx[arg.uuid]; } else if ((arg as IComponent)?.isComponent) { return ctx[(arg as IComponent).guid]; } return arg; }); newMethods.push(new CallInfo(newTarget.clone, method.methodName, newArguments, method.enabled)); } else if (isDevEnvironment()) { console.warn("Could not find target for event listener"); } } } } const newInstance = new EventList(newMethods); return newInstance; } private target?: object; private key?: string; // TODO: serialization should not take care of the args but instead give them to the eventlist directly // so we can handle passing them on here instead of in the serializer // this would also allow us to pass them on to the component EventTarget /** set an event target to try invoke the EventTarget dispatchEvent when this EventList is invoked */ setEventTarget(key: string, target: object) { this.key = key; this.target = target; if (this.key !== undefined) { let temp = ""; let foundFirstLetter = false; for (const c of this.key) { if (foundFirstLetter && isUpperCase(c)) temp += "-"; foundFirstLetter = true; temp += c.toLowerCase(); } this.key = temp; } } /** How many callback methods are subscribed to this event */ get listenerCount() { return this.methods?.length ?? 0; } /** If the event is currently being invoked */ get isInvoking() { return this._isInvoking; } private _isInvoking: boolean = false; // TODO: can we make functions serializable? private readonly methods: Array = []; private readonly _methodsCopy: Array = []; static from(...evts: Array) { return new EventList(evts); } constructor(evts?: Array | Function) { this.methods = []; if (Array.isArray(evts)) { for (const evt of evts) { if (evt instanceof CallInfo) { this.methods.push(evt); } else if (typeof evt === "function") { this.methods.push(new CallInfo(evt)); } } } else { if (typeof evts === "function") { this.methods.push(new CallInfo(evts)); } } } /** Invoke all the methods that are subscribed to this event */ invoke(...args: Array) { if (this._isInvoking) { console.warn("Circular event invocation detected. Please check your event listeners for circular references.", this); return false; } if (this.methods?.length <= 0) return false; this._isInvoking = true; try { // make a copy of the methods array to avoid issues when removing listeners during invocation this._methodsCopy.length = 0; this._methodsCopy.push(...this.methods); // first invoke all the methods that were subscribed to this eventlist for (const m of this._methodsCopy) { m.invoke(...args); } // then try to dispatch the event on the object that is owning this eventlist // with this we get automatic event listener support for unity events on all componnets // so example for a component with a click UnityEvent you can also subscribe to the component like this: // myComponent.addEventListener("click", args => {...") if (typeof this.target === "object" && typeof this.key === "string") { const fn = this.target["dispatchEvent"]; if (typeof fn === "function") { const evt = new EventListEvent(this.key); evt.args = args; fn.call(this.target, evt); } } } finally { this._isInvoking = false; this._methodsCopy.length = 0; } return true; } /** Add a new event listener to this event */ addEventListener(cb: (args: TArgs) => void): Function { this.methods.push(new CallInfo(cb)); return cb; } removeEventListener(cb: Function | null | undefined) { if (!cb) return; for (let i = this.methods.length - 1; i >= 0; i--) { if (this.methods[i].target === cb) { this.methods[i].enabled = false; this.methods.splice(i, 1); } } } removeAllEventListeners() { this.methods.length = 0; } }