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.

589 lines (511 loc) 22.7 kB
import { Euler, Object3D, Quaternion, Scene, Vector3 } from "three"; // https://github.com/uuidjs/uuid // v5 takes string and namespace import { v5 } from 'uuid'; import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry.js"; import { isDevEnvironment } from "./debug/index.js"; import { destroy, findByGuid, type IInstantiateOptions, instantiate } from "./engine_gameobject.js"; import { InstantiateOptions } from "./engine_gameobject.js"; import type { INetworkConnection } from "./engine_networking_types.js"; import type { IModel } from "./engine_networking_types.js"; import { SendQueue } from "./engine_networking_types.js"; import { Context } from "./engine_setup.js" import type { IComponent as Component, IContext,IGameObject as GameObject } from "./engine_types.js" import type { UIDProvider } from "./engine_types.js"; import * as utils from "./engine_utils.js" ContextRegistry.registerCallback(ContextEvent.ContextCreated, evt => { const context = evt.context as Context; beginListenInstantiate(context); beginListenDestroy(context); }); const debug = utils.getParam("debugcomponents"); const ID_NAMESPACE = 'eff8ba80-635d-11ec-90d6-0242ac120003'; export class InstantiateIdProvider implements UIDProvider { get seed() { return this._seed; } set seed(val: number) { this._seed = val; } private _originalSeed: number; private _seed: number; constructor(seed: string | number) { if (typeof seed === "string") { seed = InstantiateIdProvider.hash(seed); } this._originalSeed = seed; this._seed = seed; } reset() { this._seed = this._originalSeed; } generateUUID(str?: string): string { if (typeof str === "string") { return v5(str, ID_NAMESPACE); } const s = this._seed; this._seed -= 1; // console.log(s); return v5(s.toString(), ID_NAMESPACE); } initialize(strOrNumber: string | number) { if (typeof strOrNumber === "string") { this._seed = InstantiateIdProvider.hash(strOrNumber); } else { this._seed = strOrNumber; } } static createFromString(str: string) { return new InstantiateIdProvider(this.hash(str)); } private static hash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } return hash; } } export enum InstantiateEvent { NewInstanceCreated = "new-instance-created", InstanceDestroyed = "instance-destroyed", } class DestroyInstanceModel implements IModel { guid: string; dontSave?: boolean; constructor(guid: string) { this.guid = guid; } } export interface IBeforeNetworkedDestroy { onBeforeNetworkedDestroy(networkIds: string[]): void; } declare type SyncDestroyOptions = { /** When true the state will be saved in the networking backend */ saveInRoom?: boolean; } /** * Destroy an object across the network. See also {@link syncInstantiate}. * @param obj The object or component to be destroyed * @param con The network connection to send the destroy event to * @param recursive If true, all children will be destroyed as well. Default is true * @param opts Options for the destroy operation * @category Networking */ export function syncDestroy(obj: GameObject | Component, con: INetworkConnection, recursive: boolean = true, opts?: SyncDestroyOptions) { if (!obj) return; const go = obj as GameObject; destroy(obj, recursive); if (!con) { console.warn("Can not send destroy: No networking connection provided", obj.guid); return; } if (!con.isConnected) { if (isDevEnvironment()) console.debug("Can not send destroy: not connected", obj.guid); return; } let guid: string | undefined | null = obj.guid; if (!guid && go.uuid) { guid = go.uuid; } if (!guid) { console.warn("Can not send destroy: failed to find guid", obj); return; } sendDestroyed(guid, con, opts); } export function sendDestroyed(guid: string, con: INetworkConnection, opts?: SyncDestroyOptions) { const model = new DestroyInstanceModel(guid); if (opts?.saveInRoom === false) { model.dontSave = true; } con.send(InstantiateEvent.InstanceDestroyed, model, SendQueue.Queued); } declare type SyncDestroyCallback = (guid: string, object: Object3D) => void; const _onSyncDestroyCallbacks: SyncDestroyCallback[] = []; /** * Register a callback that fires when a remote `syncDestroy` event is received. * The callback receives the guid and the resolved Object3D (or null if not found in the scene). * The callback fires **before** the object is destroyed, so you can still access its state. * @param callback Called with the guid and the Object3D about to be destroyed * @returns An unsubscribe function * @category Networking * @example * ```ts * const unsub = onSyncDestroy((guid, obj) => { * console.log("Remote object destroyed:", guid, obj?.name); * }); * // later: unsub(); * ``` */ export function onSyncDestroy(callback: SyncDestroyCallback): () => void { _onSyncDestroyCallbacks.push(callback); return () => { const idx = _onSyncDestroyCallbacks.indexOf(callback); if (idx >= 0) _onSyncDestroyCallbacks.splice(idx, 1); }; } export function beginListenDestroy(context: Context) { context.connection.beginListen(InstantiateEvent.InstanceDestroyed, (data: DestroyInstanceModel) => { if (debug) console.log("[Remote] Destroyed", context.scene, data); // TODO: create global lookup table for guids const obj = findByGuid(data.guid, context.scene); if (obj) { // Notify listeners before destroying for (const cb of _onSyncDestroyCallbacks) { try { cb(data.guid, obj as Object3D); } catch (err) { console.error("Error in onSyncDestroy callback", err); } } destroy(obj); } }); } // /** * When a file is instantiated via some server (e.g. via file drop) we also want to send the info where the file can be downloaded. * @internal */ export class HostData { /** File to download */ filename: string; /** Checksum to verify its the correct file */ hash: string; /** Expected size of the referenced file and its dependencies */ size: number; constructor(filename: string, hash: string, size: number) { this.filename = filename; this.hash = hash; this.size = size; } } export class NewInstanceModel implements IModel { guid: string; originalGuid: string; seed: number | undefined; visible: boolean | undefined; hostData: HostData | undefined; dontSave?: boolean | undefined; parent: string | undefined; position: { x: number, y: number, z: number } | undefined; rotation: { x: number, y: number, z: number, w: number } | undefined; scale: { x: number, y: number, z: number } | undefined; /** Set to true to prevent this model from being instantiated */ preventCreation?: boolean = undefined; /** * When set this will delete the server state when the user disconnects */ deleteStateOnDisconnect?: boolean | undefined; constructor(originalGuid: string, newGuid: string) { this.originalGuid = originalGuid; this.guid = newGuid; } } /** * Instantiation options for {@link syncInstantiate} * @category Networking * @see {@link syncInstantiate} - Instantiate objects across the network */ export type SyncInstantiateOptions = IInstantiateOptions & Pick<IModel, "deleteOnDisconnect">; // #region Sync Instantiate /** * Callback type for {@link onSyncInstantiate} * @param instance The instantiated object * @param model The network model data sent with the instantiate event * @param context The network context in which the instantiate event was received * @category Networking */ declare type SyncInstantiateCallback = (instance: GameObject, model: NewInstanceModel, context: IContext) => void; /** Registered callbacks for remote instantiation events */ const _onSyncInstantiateCallbacks: SyncInstantiateCallback[] = []; /** * Register a callback that fires when a remote `syncInstantiate` object is created on this client. * Use this to get references to objects spawned by other users. * @param callback Called with the instantiated Object3D, the network model data, and the Needle Engine context in which the instantiate event was received * @returns An unsubscribe function * @category Networking * @example * ```ts * const unsub = onSyncInstantiate((instance, model, context) => { * console.log("Remote object created:", instance.name, model.originalGuid, context); * }); * // later: unsub(); * ``` * @see {@link syncInstantiate} - Instantiate objects across the network * @see {@link syncDestroy} - Destroy objects across the network */ export function onSyncInstantiate(callback: SyncInstantiateCallback): () => void { _onSyncInstantiateCallbacks.push(callback); return () => { const idx = _onSyncInstantiateCallbacks.indexOf(callback); if (idx >= 0) _onSyncInstantiateCallbacks.splice(idx, 1); }; } /** * Instantiate an object across the network. The object is cloned locally and a network message * is sent so all connected clients create the same clone. Late joiners receive the message * via room state replay (unless `deleteOnDisconnect` is set or `save` is false). * * ## How it works internally * 1. The prefab is cloned locally using a seeded {@link InstantiateIdProvider} * 2. The seed ensures all clients generate **identical deterministic guids** for the clone * and all its children — no need to send individual guids over the network * 3. A {@link NewInstanceModel} message is sent containing the prefab's `originalGuid`, * the clone's `guid`, the `seed`, and transform data * 4. On receiving clients, the prefab is resolved via {@link registerPrefabProvider} or * by searching the scene for an object with matching guid, then cloned with the same seed * * ## Runtime-created prefabs (no GLB) * If the object has a `guid` but no prefab provider is registered for it, `syncInstantiate` * will **auto-register** the object as a prefab provider. This means for code-only prefabs * you just need to set a `guid` — no manual `registerPrefabProvider` call needed, as long as * all clients run the same setup code that creates the same prefab with the same guid. * * @param object The object to instantiate. Must have a `guid` property (set one for runtime objects). * @param opts Options for the instantiation, including the network context to send the instantiate event to * @param hostData Optional data about a file to download when this object is instantiated (e.g. when instantiated via file drop) * @param save When false, the state of this instance will not be saved in the networking backend. Default is true. * @returns The instantiated object, or null if instantiation failed (e.g. missing guid or network context) * @see {@link syncDestroy} - Destroy objects across the network * @see {@link onSyncInstantiate} - Register a callback to get references to remotely instantiated objects * @see {@link registerPrefabProvider} - Manually register a prefab provider (auto-registered by syncInstantiate) * @see {@link unregisterPrefabProvider} - Remove a registered prefab provider * @category Networking * * @example Basic usage with a runtime-created prefab * ```ts * const cookie = ObjectUtils.createPrimitive("Cube", { color: 0xff8c00 }); * cookie.guid = "cookie-prefab"; * // No need to call registerPrefabProvider — syncInstantiate auto-registers it * syncInstantiate(cookie, { parent: ctx.scene, deleteOnDisconnect: false }); * ``` * * @example With deterministic seed (advanced) * ```ts * const idProvider = new InstantiateIdProvider("my-seed"); * const instance = syncInstantiate(prefab, { context, idProvider }); * ``` * The seed generates deterministic guids via UUID v5, so all clients produce identical * identifiers for the clone and its children without sending them over the network. */ export function syncInstantiate(object: GameObject | Object3D, opts: SyncInstantiateOptions, hostData?: HostData, save?: boolean): GameObject | null { const obj: GameObject = object as GameObject; if (!obj.guid) { // Auto-assign guid from object name if available. // The name must be the same on all clients (which it is when both run the same setup code). if (obj.name) { obj.guid = obj.name; } else { console.error("[syncInstantiate] Can not instantiate: a 'guid' is required. For runtime-created objects, either set a name (`obj.name = 'my-prefab'`) or a guid (`obj.guid = 'my-prefab-id'`). The identifier must be the same on all clients.", obj); return null; } } // Auto-register the prefab provider if none exists for this guid. // This allows runtime-created objects to work with syncInstantiate without // manual Prefabs.register calls, as long as all clients create the // same prefab with the same guid in their setup code. if (!Prefabs.has(obj.guid)) { Prefabs.register(obj.guid, async () => obj); } if (!opts.context) { opts.context = Context.Current; } if (!opts.context) { console.error("[syncInstantiate] Missing network instantiate options / reference to network connection in sync instantiate. Please pass in the Needle Engine context."); return null; } const originalOpts = opts ? { ...opts } : null; const { instance, seed } = instantiateSeeded(obj, opts); if (instance) { const go = instance as GameObject; // if (go.guid) { // const listener = GameObject.addNewComponent(go, DestroyListener); // listener.target = go; // } if (go.guid) { if (debug) console.log("[Local] new instance", "gameobject:", instance?.guid); const model = new NewInstanceModel(obj.guid, go.guid); model.seed = seed; if (opts.deleteOnDisconnect === true) model.deleteStateOnDisconnect = true; if (originalOpts) { if (originalOpts.position) { if (Array.isArray(originalOpts.position)) { model.position = { x: originalOpts.position[0], y: originalOpts.position[1], z: originalOpts.position[2] }; } else model.position = { x: originalOpts.position.x, y: originalOpts.position.y, z: originalOpts.position.z }; } if (originalOpts.rotation) { if (originalOpts.rotation instanceof Euler) { originalOpts.rotation = new Quaternion().setFromEuler(originalOpts.rotation); } else if (originalOpts.rotation instanceof Array) { originalOpts.rotation = new Quaternion().fromArray(originalOpts.rotation); } model.rotation = { x: originalOpts.rotation.x, y: originalOpts.rotation.y, z: originalOpts.rotation.z, w: originalOpts.rotation.w }; } if (originalOpts.scale) { if (Array.isArray(originalOpts.scale)) { model.scale = { x: originalOpts.scale[0], y: originalOpts.scale[1], z: originalOpts.scale[2] }; } else model.scale = { x: originalOpts.scale.x, y: originalOpts.scale.y, z: originalOpts.scale.z }; } } if (!model.position) model.position = { x: go.position.x, y: go.position.y, z: go.position.z }; if (!model.rotation) model.rotation = { x: go.quaternion.x, y: go.quaternion.y, z: go.quaternion.z, w: go.quaternion.w }; if (!model.scale) model.scale = { x: go.scale.x, y: go.scale.y, z: go.scale.z }; model.visible = obj.visible; if (originalOpts?.parent) { if (typeof originalOpts.parent === "string") model.parent = originalOpts.parent; else if (originalOpts.parent?.["guid"]) { model.parent = originalOpts.parent["guid"] } else if (originalOpts.parent instanceof Scene) { model.parent = "scene"; } else console.warn("Unsupported parent type in sync instantiate options: " + originalOpts.parent?.name); } model.hostData = hostData; if (save === false) model.dontSave = true; const con = opts?.context?.connection; if (!con && isDevEnvironment()) console.debug("Object will be instantiated but it will not be synced: not connected", obj.guid); if (opts.context.connection.isInRoom) syncedInstantiated.push(new WeakRef(go)); opts?.context?.connection.send(InstantiateEvent.NewInstanceCreated, model); } else console.warn("Missing guid, can not send new instance event", go); } return instance; } export function generateSeed(): number { return Math.random() * 9_999_999;// Number.MAX_VALUE;; } const syncedInstantiated = new Array<WeakRef<Object3D>>(); export function beginListenInstantiate(context: Context) { const cb1 = context.connection.beginListen(InstantiateEvent.NewInstanceCreated, async (model: NewInstanceModel) => { const obj: GameObject | null = await tryResolvePrefab(model.originalGuid, context.scene) as GameObject; if (model.preventCreation === true) { return; } if (!obj) { console.warn("could not find object that was instantiated: " + model.guid); return; } const options = new InstantiateOptions(); if (model.position) options.position = new Vector3(model.position.x, model.position.y, model.position.z); if (model.rotation) options.rotation = new Quaternion(model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w); if (model.scale) options.scale = new Vector3(model.scale.x, model.scale.y, model.scale.z); options.parent = model.parent; if (model.seed) options.idProvider = new InstantiateIdProvider(model.seed); options.visible = model.visible; options.context = context; if (debug && context.alias) console.log("[Remote] instantiate in: " + context.alias); const inst = instantiate(obj as GameObject, options); syncedInstantiated.push(new WeakRef(inst)); if (inst) { if (model.parent === "scene") context.scene.add(inst); if (debug) console.log("[Remote] new instance", "gameobject:", inst?.guid, obj); // Notify listeners about the remote instantiation for (const cb of _onSyncInstantiateCallbacks) { try { cb(inst, model, context); } catch (err) { console.error("Error in onSyncInstantiate callback", err); } } } }); const cb2 = context.connection.beginListen("left-room", () => { if (syncedInstantiated.length > 0) console.debug(`Left networking room, cleaning up ${syncedInstantiated.length} instantiated objects`); for (const prev of syncedInstantiated) { const obj = prev.deref(); if (obj) obj.destroy(); } syncedInstantiated.length = 0; }); return () => { cb1(); cb2(); } } function instantiateSeeded(obj: GameObject, opts: IInstantiateOptions | null): { instance: GameObject | null, seed: number } { const seed = generateSeed(); const options = opts ?? new InstantiateOptions(); options.idProvider = new InstantiateIdProvider(seed); const instance = instantiate(obj, options); return { seed: seed, instance: instance }; } export { type PrefabProviderCallback,Prefabs } from "./engine_networking_prefabs.js"; import { Prefabs } from "./engine_networking_prefabs.js"; /** * Register a prefab provider. Forwards to {@link Prefabs.register}. * @category Networking */ export function registerPrefabProvider(key: string, fn: (guid: string) => Promise<Object3D | null>) { Prefabs.register(key, fn); } /** * Unregister a prefab provider. Forwards to {@link Prefabs.unregister}. * @category Networking */ export function unregisterPrefabProvider(key: string) { Prefabs.unregister(key); } async function tryResolvePrefab(guid: string, obj: Object3D): Promise<Object3D | null> { const res = await Prefabs.resolve(guid); if (res) return res; return tryFindObjectByGuid(guid, obj) as Object3D; } function tryFindObjectByGuid(guid: string, obj: Object3D): Object3D | null { if (obj === null) return null; if (!guid) return null; if (obj["guid"] === guid) { return obj; } if (obj.children) { for (const ch of obj.children) { const found = tryFindObjectByGuid(guid, ch); if (found) { return found; } } } return null; } // class DestroyListener extends Behaviour { // target: GameObject | Component | null = null; // private destroyCallback: any; // awake(): void { // if (!this.target) { // console.log("Missing target to watch", this); // return; // } // this.destroyCallback = this.onObjectDestroyed.bind(this); // this.context.connection.beginListen(InstantiateEvent.InstanceDestroyed, this.destroyCallback); // } // onDestroy(): void { // this.context.connection.stopListening(InstantiateEvent.InstanceDestroyed, this.destroyCallback); // if (this.target && this.target.guid && this.gameObject.guid === this.target.guid) { // sendDestroyed(this.target.guid, this.context.connection); // } // } // private onObjectDestroyed(evt: DestroyInstanceModel) { // if (evt.guid === this.target?.guid) { // if (debug) // console.log("RECEIVED destroyed event", evt.guid); // GameObject.destroy(this.target); // } // } // }