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.

420 lines (348 loc) • 14.7 kB
import { Object3D, Quaternion, 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, 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); } 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) 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} */ export type SyncInstantiateOptions = IInstantiateOptions & Pick<IModel, "deleteOnDisconnect">; /** * Instantiate an object across the network. See also {@link syncDestroy}. * @category Networking */ export function syncInstantiate(object: GameObject | Object3D, opts: SyncInstantiateOptions, hostData?: HostData, save?: boolean): GameObject | null { const obj: GameObject = object as GameObject; if (!obj.guid) { console.warn("Can not instantiate: No guid", obj); return null; } if (!opts.context) opts.context = Context.Current; if (!opts.context) { console.error("Missing network instantiate options / reference to network connection in sync instantiate"); 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) model.position = { x: originalOpts.position.x, y: originalOpts.position.y, z: originalOpts.position.z }; if (originalOpts.rotation) model.rotation = { x: originalOpts.rotation.x, y: originalOpts.rotation.y, z: originalOpts.rotation.z, w: originalOpts.rotation.w }; if (originalOpts.scale) 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 model.parent = originalOpts.parent["guid"]; } 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) { 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); } }); 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; }) } 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 declare type PrefabProviderCallback = (guid: string) => Promise<Object3D | null>; const registeredPrefabProviders: { [key: string]: PrefabProviderCallback } = {}; //** e.g. provide a function that can return a instantiated object when instantiation event is received */ export function registerPrefabProvider(key: string, fn: PrefabProviderCallback) { registeredPrefabProviders[key] = fn; } async function tryResolvePrefab(guid: string, obj: Object3D): Promise<Object3D | null> { const prov = registeredPrefabProviders[guid]; if (prov !== null && prov !== undefined) { const res = await prov(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); // } // } // }