import { isDevEnvironment } from "../engine/debug/index.js"; import type { IComponent, IEventList } from "../engine/engine_types.js"; const argumentsBuffer = new Array(); /** * CallInfo represents a single callback method that can be invoked by the {@link EventList}. */ export class CallInfo { /** @internal Used by the instantiate resolver to recursively resolve references */ static { CallInfo.prototype.$serializedTypes = { target: Object, arguments: Array }; } declare $serializedTypes: Record; /** * 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(...argumentsBuffer); 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) { if (args !== undefined && args.length > 0) this.target[this.methodName] = args[0]; else this.target[this.methodName] = this.arguments[0]; } else { this.target[this.methodName] = args[0]; } } } } } /** @deprecated No longer automatically dispatched. Use `eventList.on()` directly instead. */ export class EventListEvent extends Event { //implements ArrayLike { args?: TArgs; } /** * Plain-data form of a callback entry, accepted by {@link EventList.addCall} so callers * don't have to construct a {@link CallInfo} themselves. Field names match CallInfo. */ export type EventListCallData = { /** The object to invoke the method on OR the function to invoke. Editors may create * entries without a target and assign it afterwards. */ target?: Object | Function; methodName?: string | null; arguments?: Array; enabled?: boolean; }; /** * EventList manages a list of callbacks that can be invoked together. * Used for Unity-style events that can be configured in the editor (Unity or Blender). * * **Serialization:** * EventLists are serializable - callbacks configured in Unity/Blender will work at runtime. * Mark fields with `@serializable(EventList)` for editor support. * * **Usage patterns:** * - Button click handlers * - Animation events * - Custom component callbacks * - Scene loading events * * ![](https://cloud.needle.tools/-/media/P7bEKQvfgRUMTb2Wi1hWXg.png) * *Screenshot of a Unity component with an EventList field* * * ![](https://cloud.needle.tools/-/media/i2hi2OHfbaDyHyBL6Gt58A.png) * *Screenshot of a Blender component with an EventList field* * * @example Create and use an EventList * ```ts * // Define in your component * @serializable(EventList) * onClick: EventList = new EventList(); * * // Add listeners * this.onClick.addEventListener(() => console.log("Clicked!")); * * // Invoke all listeners * this.onClick.invoke(); * ``` * * @example Listen with arguments * ```ts * const onScore = new EventList<{ points: number }>(); * onScore.addEventListener(data => console.log("Scored:", data.points)); * onScore.invoke({ points: 100 }); * ``` * * @category Events * @group Utilities * @see {@link CallInfo} for individual callback configuration * @see {@link Button} for UI button events */ export class EventList implements IEventList { /** @internal Used by the instantiate resolver to recursively resolve references */ static { EventList.prototype.$serializedTypes = { methods: Array }; } declare $serializedTypes: Record; /** checked during instantiate to create a new instance */ readonly isEventList = true; /** How many callback methods are subscribed to this event */ get listenerCount() { return this.methods?.length ?? 0; } /** * The callback entries of this event. * This is tooling/editor surface: inspectors render and edit the entries (a * {@link CallInfo}'s fields are mutable), serializers read them. Use * {@link addCall} / {@link removeCall} to change the list itself. */ get calls(): readonly CallInfo[] { return this.methods; } /** * Add a callback entry to this event. Accepts a plain function, a {@link CallInfo} * instance or a plain data object — `addCall({ target, methodName: "myMethod" })`. * @param index optional position to insert at (appends when omitted or out of range) — * entry order is invocation order * @returns a function that removes the entry again (same contract as * {@link addEventListener} / {@link on}); the entries themselves are readable and * editable through {@link calls} */ addCall(call: CallInfo | EventListCallData | Function | null | undefined, index?: number): () => boolean { if (call === null || call === undefined) { console.warn("EventList.addCall: no call provided — nothing was added."); return () => false; } let entry: CallInfo; if (call instanceof CallInfo) { entry = call; } else if (typeof call === "function") { entry = new CallInfo(call); } else { entry = new CallInfo(call.target as object, call.methodName ?? null, call.arguments, call.enabled); } if (index === undefined || index < 0 || index >= this.methods.length) { this.methods.push(entry); } else { this.methods.splice(index, 0, entry); } return () => this.removeCall(entry); } /** * Remove a callback entry (or the entry at an index) from this event. * @returns true if an entry was removed */ removeCall(call: CallInfo | number): boolean { const index = typeof call === "number" ? call : this.methods.indexOf(call); if (index < 0 || index >= this.methods.length) return false; this.methods.splice(index, 1); return true; } /** 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 = []; /** * Create a new EventList with the given callback methods. You can pass either CallInfo instances or functions directly. * @returns a new EventList instance with the given callback methods * @example * ```ts * const onClick = EventList.from( * () => console.log("Clicked!"), * new CallInfo(someObject, "someMethod", [arg1, arg2]) * ); * onClick.invoke(); * ``` */ static from(...evts: Array) { return new EventList(evts); } /** * Create a new EventList with the given callback methods. You can pass either CallInfo instances or functions directly. * @returns a new EventList instance with the given callback methods */ 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 * @param args optional arguments to pass to the event listeners. These will be passed before any custom arguments defined in the CallInfo instances. So if you have a CallInfo with arguments and you also pass arguments to invoke, the arguments passed to invoke will take precedence over the CallInfo arguments. * @returns true if the event was successfully invoked, false if there are no listeners or if a circular invocation was detected */ 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); for (const m of this._methodsCopy) { m.invoke(...args); } } finally { this._isInvoking = false; this._methodsCopy.length = 0; } return true; } /** Add a new event listener to this event * @returns a function to remove the event listener * @see {@link removeEventListener} for more details and return value information * @see {@link off} for an alias with better readability when unsubscribing from events * @example * ```ts * const off = myEvent.addEventListener(args => console.log("Clicked!", args)); * // later * off(); * ``` */ addEventListener(callback: (args: TArgs) => void): Function { this.methods.push(new CallInfo(callback)); return () => this.removeEventListener(callback); } /** * Alias for addEventListener for better readability when subscribing to events. You can use it like this: * ```ts * myEvent.on(args => console.log("Clicked!", args)); * ``` * @returns a function to remove the event listener * @see {@link addEventListener} for more details and return value information */ on(callback: (args: TArgs) => void): Function { return this.addEventListener(callback); } /** * Remove an event listener from this event. * @returns true if the event listener was found and removed, false otherwise */ removeEventListener(fn: Function | null | undefined) { if (!fn) return false; let found = false; for (let i = this.methods.length - 1; i >= 0; i--) { if (this.methods[i].target === fn) { this.methods[i].enabled = false; this.methods.splice(i, 1); found = true; } } return found; } /** * Alias for removeEventListener for better readability when unsubscribing from events. You can use it like this: * ```ts * const off = myEvent.on(args => console.log("Clicked!", args)); * // later * off(); * ``` * * @see {@link removeEventListener} for more details and return value information */ off(callback: Function | null | undefined) { return this.removeEventListener(callback); } /** * Remove all event listeners from this event. Use with caution! This will remove all listeners! */ removeAllEventListeners() { this.methods.length = 0; } }