UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

309 lines (274 loc) • 11.1 kB
import { isDevEnvironment } from "../engine/debug/index.js"; import type { IComponent, IEventList } from "../engine/engine_types.js"; const argumentsBuffer = new Array<any>(); /** * 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<string, any>; /** * 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<any>; get canClone() { return this.target instanceof Object; } constructor(target: Function); constructor(target: Object, methodName: string | null, args?: Array<any>, enabled?: boolean); constructor(target: Object | Function, methodName?: string | null, args?: Array<any>, 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) { 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<TArgs extends any> extends Event { //implements ArrayLike<T> { args?: TArgs; } /** * 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<TArgs extends any = any> implements IEventList { /** @internal Used by the instantiate resolver to recursively resolve references */ static { EventList.prototype.$serializedTypes = { methods: Array }; } declare $serializedTypes: Record<string, any>; /** 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; } /** 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<CallInfo> = []; private readonly _methodsCopy: Array<CallInfo> = []; /** * 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<Function>) { 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<CallInfo | Function> | 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<TArgs>) { 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; } }