@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
text/typescript
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
*
* 
* *Screenshot of a Unity component with an EventList field*
*
* 
* *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;
}
}