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.

921 lines • 34.5 kB
import { Object3D, Vector3 } from "three"; import { isDevEnvironment } from "../engine/debug/index.js"; import { addComponent, destroyComponentInstance, findObjectOfType, findObjectsOfType, getComponent, getComponentInChildren, getComponentInParent, getComponents, getComponentsInChildren, getComponentsInParent, getOrAddComponent, removeComponent } from "../engine/engine_components.js"; import { activeInHierarchyFieldName } from "../engine/engine_constants.js"; import { destroy, findByGuid, foreachComponent, instantiate, isActiveInHierarchy, isActiveSelf, isDestroyed, isUsingInstancing, markAsInstancedRendered, setActive } from "../engine/engine_gameobject.js"; import { isHotReloadEnabled, registerHotReloadType, unregisterHotReloadType } from "../engine/engine_hot_reload.js"; import * as main from "../engine/engine_mainloop_utils.js"; import { syncDestroy, syncInstantiate } from "../engine/engine_networking_instantiate.js"; import { Context, FrameEvent } from "../engine/engine_setup.js"; import * as threeutils from "../engine/engine_three_utils.js"; // export interface ISerializationCallbackReceiver { // onBeforeSerialize?(): object | void; // onAfterSerialize?(); // onBeforeDeserialize?(data?: any); // onAfterDeserialize?(); // onDeserialize?(key: string, value: any): any | void; // } /** * Base class for objects in Needle Engine. Extends {@link Object3D} from three.js. * GameObjects can have components attached to them, which can be used to add functionality to the object. * They manage their components and provide methods to add, remove and get components. * * All {@link Object3D} types loaded in Needle Engine have methods like {@link addComponent}. * These methods are available directly on the GameObject instance: * ```typescript * target.addComponent(MyComponent); * ``` * * And can be called statically on the GameObject class as well: * ```typescript * GameObject.setActive(target, true); * ``` */ export class GameObject extends Object3D { /** * Unique identifier for this GameObject */ guid; /** * Checks if a GameObject has been destroyed * @param go The GameObject to check * @returns True if the GameObject has been destroyed */ static isDestroyed(go) { return isDestroyed(go); } /** * Sets the active state of a GameObject * @param go The GameObject to modify * @param active Whether the GameObject should be active * @param processStart Whether to process the start callbacks if being activated */ static setActive(go, active, processStart = true) { if (!go) return; setActive(go, active); // TODO: do we still need this?: main.updateIsActive(go); if (active && processStart) main.processStart(Context.Current, go); } /** * Checks if the GameObject itself is active (same as go.visible) * @param go The GameObject to check * @returns True if the GameObject is active */ static isActiveSelf(go) { return isActiveSelf(go); } /** * Checks if the GameObject is active in the hierarchy (e.g. if any parent is invisible or not in the scene it will be false) * @param go The GameObject to check * @returns True if the GameObject is active in the hierarchy */ static isActiveInHierarchy(go) { return isActiveInHierarchy(go); } /** * Marks a GameObject to be rendered using instancing * @param go The GameObject to mark * @param instanced Whether the GameObject should use instanced rendering */ static markAsInstancedRendered(go, instanced) { markAsInstancedRendered(go, instanced); } /** * Checks if a GameObject is using instanced rendering * @param instance The GameObject to check * @returns True if the GameObject is using instanced rendering */ static isUsingInstancing(instance) { return isUsingInstancing(instance); } /** * Executes a callback for all components of the provided type on the provided object and its children * @param instance Object to run the method on * @param cb Callback to run on each component, "return undefined;" to continue and "return <anything>;" to break the loop * @param recursive If true, the method will be run on all children as well * @returns The last return value of the callback */ static foreachComponent(instance, cb, recursive = true) { return foreachComponent(instance, cb, recursive); } /** * Creates a new instance of the provided object that will be replicated to all connected clients * @param instance Object to instantiate * @param opts Options for the instantiation * @returns The newly created instance or null if creation failed */ static instantiateSynced(instance, opts) { if (!instance) return null; return syncInstantiate(instance, opts); } static instantiate(instance, opts = null) { if ('isAssetReference' in instance) { return instantiate(instance, opts); } return instantiate(instance, opts); } /** * Destroys an object on all connected clients (if in a networked session) * @param instance Object to destroy * @param context Optional context to use * @param recursive If true, all children will be destroyed as well */ static destroySynced(instance, context, recursive = true) { if (!instance) return; const go = instance; context = context ?? Context.Current; syncDestroy(go, context.connection, recursive); } /** * Destroys an object * @param instance Object to destroy * @param recursive If true, all children will be destroyed as well. Default: true */ static destroy(instance, recursive = true) { return destroy(instance, recursive); } /** * Adds an object to parent and ensures all components are properly registered * @param instance Object to add * @param parent Parent to add the object to * @param context Optional context to use */ static add(instance, parent, context) { if (!instance || !parent) return; if (instance === parent) { console.warn("Can not add object to self", instance); return; } if (!context) { context = Context.Current; } parent.add(instance); setActive(instance, true); main.updateIsActive(instance); if (context) { GameObject.foreachComponent(instance, (comp) => { main.addScriptToArrays(comp, context); if (comp.__internalDidAwakeAndStart) return; if (context.new_script_start.includes(comp) === false) { context.new_script_start.push(comp); } }, true); } else { console.warn("Missing context"); } } /** * Removes the object from its parent and deactivates all of its components * @param instance Object to remove */ static remove(instance) { if (!instance) return; instance.parent?.remove(instance); setActive(instance, false); main.updateIsActive(instance); GameObject.foreachComponent(instance, (comp) => { main.processRemoveFromScene(comp); }, true); } /** * Invokes a method on all components including children (if a method with that name exists) * @param go GameObject to invoke the method on * @param functionName Name of the method to invoke * @param args Arguments to pass to the method */ static invokeOnChildren(go, functionName, ...args) { this.invoke(go, functionName, true, args); } /** * Invokes a method on all components that have a method matching the provided name * @param go GameObject to invoke the method on * @param functionName Name of the method to invoke * @param children Whether to invoke on children as well * @param args Arguments to pass to the method */ static invoke(go, functionName, children = false, ...args) { if (!go) return; this.foreachComponent(go, c => { const fn = c[functionName]; if (fn && typeof fn === "function") { fn?.call(c, ...args); } }, children); } /** @deprecated use `addComponent` */ // eslint-disable-next-line deprecation/deprecation static addNewComponent(go, type, init, callAwake = true) { return addComponent(go, type, init, { callAwake }); } /** * Adds a new component (or moves an existing component) to the provided object * @param go Object to add the component to * @param instanceOrType If an instance is provided it will be moved to the new object, if a type is provided a new instance will be created * @param init Optional init object to initialize the component with * @param opts Optional options for adding the component * @returns The added or moved component */ static addComponent(go, instanceOrType, init, opts) { return addComponent(go, instanceOrType, init, opts); } /** * Moves a component to a new object * @param go GameObject to move the component to * @param instance Component to move * @returns The moved component */ static moveComponent(go, instance) { return addComponent(go, instance); } /** * Removes a component from its object * @param instance Component to remove * @returns The removed component */ static removeComponent(instance) { removeComponent(instance.gameObject, instance); return instance; } /** * Gets or adds a component of the specified type * @param go GameObject to get or add the component to * @param typeName Constructor of the component type * @returns The existing or newly added component */ static getOrAddComponent(go, typeName) { return getOrAddComponent(go, typeName); } /** * Gets a component on the provided object * @param go GameObject to get the component from * @param typeName Constructor of the component type * @returns The component if found, otherwise null */ static getComponent(go, typeName) { if (go === null) return null; // if names are minified we could also use the type store and work with strings everywhere // not ideal, but I dont know a good/sane way to do this otherwise // const res = TypeStore.get(typeName); // if(res) typeName = res; return getComponent(go, typeName); } /** * Gets all components of the specified type on the provided object * @param go GameObject to get the components from * @param typeName Constructor of the component type * @param arr Optional array to populate with the components * @returns Array of components */ static getComponents(go, typeName, arr = null) { if (go === null) return arr ?? []; return getComponents(go, typeName, arr); } /** * Finds an object or component by its unique identifier * @param guid Unique identifier to search for * @param hierarchy Root object to search in * @returns The found GameObject or Component, or null/undefined if not found */ static findByGuid(guid, hierarchy) { const res = findByGuid(guid, hierarchy); return res; } /** * Finds the first object of the specified component type in the scene * @param typeName Constructor of the component type * @param context Context or root object to search in * @param includeInactive Whether to include inactive objects in the search * @returns The first matching component if found, otherwise null */ static findObjectOfType(typeName, context, includeInactive = true) { return findObjectOfType(typeName, context ?? Context.Current, includeInactive); } /** * Finds all objects of the specified component type in the scene * @param typeName Constructor of the component type * @param context Context or root object to search in * @returns Array of matching components */ static findObjectsOfType(typeName, context) { const arr = []; findObjectsOfType(typeName, arr, context); return arr; } /** * Gets a component of the specified type in the gameObject's children hierarchy * @param go GameObject to search in * @param typeName Constructor of the component type * @returns The first matching component if found, otherwise null */ static getComponentInChildren(go, typeName) { return getComponentInChildren(go, typeName); } /** * Gets all components of the specified type in the gameObject's children hierarchy * @param go GameObject to search in * @param typeName Constructor of the component type * @param arr Optional array to populate with the components * @returns Array of components */ static getComponentsInChildren(go, typeName, arr = null) { return getComponentsInChildren(go, typeName, arr ?? undefined); } /** * Gets a component of the specified type in the gameObject's parent hierarchy * @param go GameObject to search in * @param typeName Constructor of the component type * @returns The first matching component if found, otherwise null */ static getComponentInParent(go, typeName) { return getComponentInParent(go, typeName); } /** * Gets all components of the specified type in the gameObject's parent hierarchy * @param go GameObject to search in * @param typeName Constructor of the component type * @param arr Optional array to populate with the components * @returns Array of components */ static getComponentsInParent(go, typeName, arr = null) { return getComponentsInParent(go, typeName, arr); } /** * Gets all components on the gameObject * @param go GameObject to get components from * @returns Array of all components */ static getAllComponents(go) { const componentsList = go.userData?.components; if (!componentsList) return []; const newList = [...componentsList]; return newList; } /** * Iterates through all components on the gameObject * @param go GameObject to iterate components on * @returns Generator yielding each component */ static *iterateComponents(go) { const list = go?.userData?.components; if (list && Array.isArray(list)) { for (let i = 0; i < list.length; i++) { yield list[i]; } } } } /** * Needle Engine component base class. Component's are the main building blocks of the Needle Engine. * Derive from {@link Behaviour} to implement your own using the provided lifecycle methods. * Components can be added to any {@link Object3D} using {@link addComponent} or {@link GameObject.addComponent}. * * The most common lifecycle methods are {@link update}, {@link awake}, {@link start}, {@link onEnable}, {@link onDisable} and {@link onDestroy}. * * XR specific callbacks include {@link onEnterXR}, {@link onLeaveXR}, {@link onUpdateXR}, {@link onXRControllerAdded} and {@link onXRControllerRemoved}. * * To receive pointer events implement {@link onPointerDown}, {@link onPointerUp}, {@link onPointerEnter}, {@link onPointerExit} and {@link onPointerMove}. * * @example * ```typescript * import { Behaviour } from "@needle-tools/engine"; * export class MyComponent extends Behaviour { * start() { * console.log("Hello World"); * } * update() { * console.log("Frame", this.context.time.frame); * } * } * ``` * * @group Components */ export class Component { /** * Indicates whether this object is a component * @internal */ get isComponent() { return true; } __context; /** * The context this component belongs to, providing access to the runtime environment * including physics, timing utilities, camera, and scene */ get context() { return this.__context ?? Context.Current; } set context(context) { this.__context = context; } /** * Shorthand accessor for the current scene from the context * @returns The scene this component belongs to */ get scene() { return this.context.scene; } /** * The layer value of the GameObject this component is attached to * Used for visibility and physics filtering */ get layer() { return this.gameObject?.userData?.layer; } /** * The name of the GameObject this component is attached to * Used for debugging and finding objects */ get name() { if (this.gameObject?.name) { return this.gameObject.name; } return this.gameObject?.userData.name; } __name; set name(str) { if (this.gameObject) { if (!this.gameObject.userData) this.gameObject.userData = {}; this.gameObject.userData.name = str; this.__name = str; } else { this.__name = str; } } /** * The tag of the GameObject this component is attached to * Used for categorizing objects and efficient lookup */ get tag() { return this.gameObject?.userData.tag; } set tag(str) { if (this.gameObject) { if (!this.gameObject.userData) this.gameObject.userData = {}; this.gameObject.userData.tag = str; } } /** * Indicates whether the GameObject is marked as static * Static objects typically don't move and can be optimized by the engine */ get static() { return this.gameObject?.userData.static; } set static(value) { if (this.gameObject) { if (!this.gameObject.userData) this.gameObject.userData = {}; this.gameObject.userData.static = value; } } // get hideFlags(): HideFlags { // return this.gameObject?.hideFlags; // } /** * Checks if this component is currently active (enabled and part of an active GameObject hierarchy) * Components that are inactive won't receive lifecycle method calls * @returns True if the component is enabled and all parent GameObjects are active */ get activeAndEnabled() { if (this.destroyed) return false; if (this.__isEnabled === false) return false; if (!this.__isActiveInHierarchy) return false; // let go = this.gameObject; // do { // // console.log(go.name, go.visible) // if (!go.visible) return false; // go = go.parent as GameObject; // } // while (go); return true; } get __isActive() { return this.gameObject.visible; } get __isActiveInHierarchy() { if (!this.gameObject) return false; const res = this.gameObject[activeInHierarchyFieldName]; if (res === undefined) return true; return res; } set __isActiveInHierarchy(val) { if (!this.gameObject) return; this.gameObject[activeInHierarchyFieldName] = val; } /** * Reference to the GameObject this component is attached to * This is a three.js Object3D with additional GameObject functionality */ gameObject; /** * Unique identifier for this component instance, * used for finding and tracking components */ guid = "invalid"; /** * Identifier for the source asset that created this component. * For example, URL to the glTF file this component was loaded from */ sourceId; /** * Called once when the component becomes active for the first time. * This is the first lifecycle callback to be invoked */ awake() { } /** * Called every time the component becomes enabled or active in the hierarchy. * Invoked after {@link awake} and before {@link start}. */ onEnable() { } /** * Called every time the component becomes disabled or inactive in the hierarchy. * Invoked when the component or any parent GameObject becomes invisible */ onDisable() { } /** * Called when the component is destroyed. * Use for cleanup operations like removing event listeners */ onDestroy() { this.__destroyed = true; } /** * Starts a coroutine that can yield to wait for events. * Coroutines allow for time-based sequencing of operations without blocking. * Coroutines are based on generator functions, a JavaScript language feature. * * @param routine Generator function to start * @param evt Event to register the coroutine for (default: FrameEvent.Update) * @returns The generator function that can be used to stop the coroutine * @example * Time-based sequencing of operations * ```ts * *myCoroutine() { * yield WaitForSeconds(1); // wait for 1 second * yield WaitForFrames(10); // wait for 10 frames * yield new Promise(resolve => setTimeout(resolve, 1000)); // wait for a promise to resolve * } * ``` * @example * Coroutine that logs a message every 5 frames * ```ts * onEnable() { * this.startCoroutine(this.myCoroutine()); * } * private *myCoroutine() { * while(this.activeAndEnabled) { * console.log("Hello World", this.context.time.frame); * // wait for 5 frames * for(let i = 0; i < 5; i++) yield; * } * } * ``` */ startCoroutine(routine, evt = FrameEvent.Update) { return this.context.registerCoroutineUpdate(this, routine, evt); } /** * Stops a coroutine that was previously started with startCoroutine * @param routine The routine to be stopped * @param evt The frame event the routine was registered with */ stopCoroutine(routine, evt = FrameEvent.Update) { this.context.unregisterCoroutineUpdate(routine, evt); } /** * Checks if this component has been destroyed * @returns True if the component or its GameObject has been destroyed */ get destroyed() { return this.__destroyed; } /** * Destroys this component and removes it from its GameObject * After destruction, the component will no longer receive lifecycle callbacks */ destroy() { if (this.__destroyed) return; this.__internalDestroy(); } /** @internal */ __didAwake = false; /** @internal */ __didStart = false; /** @internal */ __didEnable = false; /** @internal */ __isEnabled = undefined; /** @internal */ __destroyed = false; /** @internal */ get __internalDidAwakeAndStart() { return this.__didAwake && this.__didStart; } /** @internal */ constructor(init) { this.__didAwake = false; this.__didStart = false; this.__didEnable = false; this.__isEnabled = undefined; this.__destroyed = false; this._internalInit(init); if (isHotReloadEnabled()) registerHotReloadType(this); } /** @internal */ __internalNewInstanceCreated(init) { this.__didAwake = false; this.__didStart = false; this.__didEnable = false; this.__isEnabled = undefined; this.__destroyed = false; this._internalInit(init); return this; } /** * Initializes component properties from an initialization object * @param init Object with properties to copy to this component * @internal */ _internalInit(init) { if (typeof init === "object") { for (const key of Object.keys(init)) { const value = init[key]; // we don't want to allow overriding functions via init if (typeof value === "function") continue; this[key] = value; } } } /** @internal */ __internalAwake() { if (this.__didAwake) return; this.__didAwake = true; this.awake(); } /** @internal */ __internalStart() { if (this.__didStart) return; this.__didStart = true; if (this.start) this.start(); } /** @internal */ __internalEnable(isAddingToScene) { if (this.__destroyed) { if (isDevEnvironment()) { console.warn("[Needle Engine Dev] Trying to enable destroyed component"); } return false; } // Don't change enable before awake // But a user can change enable during awake if (!this.__didAwake) return false; if (this.__didEnable) { // We dont want to change the enable state if we are adding to scene // But we want to change the state when e.g. a user changes the enable state during awake if (isAddingToScene !== true) this.__isEnabled = true; return false; } // console.trace("INTERNAL ENABLE"); this.__didEnable = true; this.__isEnabled = true; this.onEnable(); return true; } /** @internal */ __internalDisable(isRemovingFromScene) { // Don't change enable before awake // But a user can change enable during awake if (!this.__didAwake) return; if (!this.__didEnable) { // We dont want to change the enable state if we are removing from a scene if (isRemovingFromScene !== true) this.__isEnabled = false; return; } this.__didEnable = false; this.__isEnabled = false; this.onDisable(); } /** @internal */ __internalDestroy() { if (this.__destroyed) return; this.__destroyed = true; if (this.__didAwake) { this.onDestroy?.call(this); this.dispatchEvent(new CustomEvent("destroyed", { detail: this })); } destroyComponentInstance(this); if (isHotReloadEnabled()) unregisterHotReloadType(this); } /** * Controls whether this component is enabled * Disabled components don't receive lifecycle callbacks */ get enabled() { return typeof this.__isEnabled === "boolean" ? this.__isEnabled : true; // if it has no enabled field it is always enabled } set enabled(val) { if (this.__destroyed) { if (isDevEnvironment()) { console.warn(`[Needle Engine Dev] Trying to ${val ? "enable" : "disable"} destroyed component`); } return; } // when called from animationclip we receive numbers // due to interpolation they can be anything between 0 and 1 if (typeof val === "number") { if (val >= 0.5) val = true; else val = false; } // need to check here because codegen is calling this before everything is setup if (!this.__didAwake) { this.__isEnabled = val; return; } if (val) { this.__internalEnable(); } else { this.__internalDisable(); } } /** * Gets the position of this component's GameObject in world space. * Note: This is equivalent to calling `this.gameObject.worldPosition` */ get worldPosition() { return threeutils.getWorldPosition(this.gameObject); } /** * Sets the position of this component's GameObject in world space * @param val The world position vector to set */ set worldPosition(val) { threeutils.setWorldPosition(this.gameObject, val); } /** * Sets the position of this component's GameObject in world space using individual coordinates * @param x X-coordinate in world space * @param y Y-coordinate in world space * @param z Z-coordinate in world space */ setWorldPosition(x, y, z) { threeutils.setWorldPositionXYZ(this.gameObject, x, y, z); } /** * Gets the rotation of this component's GameObject in world space as a quaternion * Note: This is equivalent to calling `this.gameObject.worldQuaternion` */ get worldQuaternion() { return threeutils.getWorldQuaternion(this.gameObject); } /** * Sets the rotation of this component's GameObject in world space using a quaternion * @param val The world rotation quaternion to set */ set worldQuaternion(val) { threeutils.setWorldQuaternion(this.gameObject, val); } /** * Sets the rotation of this component's GameObject in world space using quaternion components * @param x X component of the quaternion * @param y Y component of the quaternion * @param z Z component of the quaternion * @param w W component of the quaternion */ setWorldQuaternion(x, y, z, w) { threeutils.setWorldQuaternionXYZW(this.gameObject, x, y, z, w); } /** * Gets the rotation of this component's GameObject in world space as Euler angles (in radians) */ get worldEuler() { return threeutils.getWorldEuler(this.gameObject); } /** * Sets the rotation of this component's GameObject in world space using Euler angles (in radians) * @param val The world rotation Euler angles to set */ set worldEuler(val) { threeutils.setWorldEuler(this.gameObject, val); } /** * Gets the rotation of this component's GameObject in world space as Euler angles (in degrees) * Note: This is equivalent to calling `this.gameObject.worldRotation` */ get worldRotation() { return this.gameObject.worldRotation; } /** * Sets the rotation of this component's GameObject in world space using Euler angles (in degrees) * @param val The world rotation vector to set (in degrees) */ set worldRotation(val) { this.setWorldRotation(val.x, val.y, val.z, true); } /** * Sets the rotation of this component's GameObject in world space using individual Euler angles * @param x X-axis rotation * @param y Y-axis rotation * @param z Z-axis rotation * @param degrees Whether the values are in degrees (true) or radians (false) */ setWorldRotation(x, y, z, degrees = true) { threeutils.setWorldRotationXYZ(this.gameObject, x, y, z, degrees); } static _forward = new Vector3(); /** * Gets the forward direction vector (0,0,-1) of this component's GameObject in world space */ get forward() { return Component._forward.set(0, 0, -1).applyQuaternion(this.worldQuaternion); } static _right = new Vector3(); /** * Gets the right direction vector (1,0,0) of this component's GameObject in world space */ get right() { return Component._right.set(1, 0, 0).applyQuaternion(this.worldQuaternion); } static _up = new Vector3(); /** * Gets the up direction vector (0,1,0) of this component's GameObject in world space */ get up() { return Component._up.set(0, 1, 0).applyQuaternion(this.worldQuaternion); } // EventTarget implementation: /** * Storage for event listeners registered to this component * @private */ _eventListeners = new Map(); /** * Registers an event listener for the specified event type * @param type The event type to listen for * @param listener The callback function to execute when the event occurs */ addEventListener(type, listener) { this._eventListeners[type] = this._eventListeners[type] || []; this._eventListeners[type].push(listener); } /** * Removes a previously registered event listener * @param type The event type the listener was registered for * @param listener The callback function to remove */ removeEventListener(type, listener) { if (!this._eventListeners[type]) return; const index = this._eventListeners[type].indexOf(listener); if (index >= 0) this._eventListeners[type].splice(index, 1); } /** * Dispatches an event to all registered listeners * @param evt The event object to dispatch * @returns Always returns false (standard implementation of EventTarget) */ dispatchEvent(evt) { if (!evt || !this._eventListeners[evt.type]) return false; const listeners = this._eventListeners[evt.type]; for (let i = 0; i < listeners.length; i++) { listeners[i](evt); } return false; } } // For legacy reasons we need to export this as well // (and we don't use extend to inherit the component docs) export { Component as Behaviour }; //# sourceMappingURL=Component.js.map