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.

311 lines • 12.4 kB
import { isDevEnvironment } from "./debug/index.js"; import { SendQueue } from "./engine_networking_types.js"; import { getParam } from "./engine_utils.js"; const debug = getParam("debugautosync"); const $syncerId = Symbol("syncerId"); class ComponentsSyncerManager { _syncers = {}; getOrCreateSyncer(comp) { if (!comp.guid) return null; if (this._syncers[comp.guid]) return this._syncers[comp.guid]; const syncer = new ComponentPropertiesSyncer(comp); syncer[$syncerId] = comp.guid; this._syncers[syncer[$syncerId]] = syncer; return syncer; } removeSyncer(syncer) { delete this._syncers[syncer[$syncerId]]; } } const syncerHandler = new ComponentsSyncerManager(); /** * Collects and bundles all changes in properties per component in a frame */ class ComponentPropertiesSyncer { comp; constructor(comp) { this.comp = comp; } // private getters: { [key: string]: Function } = {}; hasChanges = false; changedProperties = {}; get networkingKey() { return this.comp.guid; } /** is set to true in on receive call to avoid circular sending */ _isReceiving = false; _isInit = false; init(comp) { if (this._isInit) return; this._isInit = true; this.comp = comp; // console.log("INIT", this.comp.name, this.networkingKey); this.comp.context.post_render_callbacks.push(this.onHandleSending); this.comp.context.connection.beginListen(this.networkingKey, this.onHandleReceiving); const state = this.comp.context.connection.tryGetState(this.comp.guid); if (state) this.onHandleReceiving(state); } destroy() { if (!this._isInit) return; this.comp.context.post_render_callbacks.splice(this.comp.context.post_render_callbacks.indexOf(this.onHandleSending), 1); this.comp.context.connection.stopListen(this.networkingKey, this.onHandleReceiving); //@ts-ignore this.comp = null; this._isInit = false; } notifyChanged(propertyName, value) { if (this._isReceiving) return; if (debug) console.log("Property changed: " + propertyName, value); this.hasChanges = true; this.changedProperties[propertyName] = value; } onHandleSending = () => { if (!this.hasChanges) return; this.hasChanges = false; const net = this.comp.context.connection; if (!net || !net.isConnected || !net.isInRoom) { for (const key in this.changedProperties) delete this.changedProperties[key]; return; } for (const name in this.changedProperties) { const value = this.changedProperties[name]; if (debug) console.log("SEND", this.comp.guid, this.networkingKey); net.send(this.networkingKey, { guid: this.comp.guid, property: name, data: value }, SendQueue.Queued); delete this.changedProperties[name]; } }; onHandleReceiving = (val) => { if (debug) console.log("SYNCFIELD RECEIVE", this.comp.name, this.comp.guid, val); if (!this._isInit) return; if (!this.comp) return; // check if this change belongs to this component if (val.guid !== this.comp.guid) { return; } try { this._isReceiving = true; this.comp[val.property] = val.data; } catch (err) { console.error(err); } finally { this._isReceiving = false; } }; } function testValueChanged(newValue, previousValue) { let valueChanged = previousValue !== newValue; if (!valueChanged && newValue && previousValue) { // TODO: array are reference types // so we need to copy the previous array if we really want to compare it if (Array.isArray(newValue) && Array.isArray(previousValue)) { valueChanged = true; // if (value.length !== previousValue.length) { // shouldSend = true; // } // else { // for (let i = 0; i < value.length; i++) { // if (value[i] !== previousValue[i]) { // shouldSend = true; // break; // } // } // } } else if (typeof newValue === "object" && typeof previousValue === "object") { valueChanged = true; // The following code doesnt work because the object is a reference type // To properly detect changes we would have to detect assignments for each property # // OR keep a copy of the previous object // for (const key of Object.keys(newValue)) { // if (newValue[key] !== previousValue[key]) { // valueChanged = true; // break; // } // } } } return valueChanged; } const $syncer = Symbol("AutoSyncHandler"); function getSyncer(instance) { if (instance[$syncer]) { return instance[$syncer]; } const syncer = syncerHandler.getOrCreateSyncer(instance); syncer?.init(instance); instance[$syncer] = syncer; return syncer; } function destroySyncer(instance) { const syncer = instance[$syncer]; if (syncer) { syncerHandler.removeSyncer(syncer); syncer.destroy(); delete instance[$syncer]; } } /** * **Decorate a field to be automatically networked synced** * *Primitive* values are all automatically synced (like string, boolean, number). * For *arrays or objects* make sure to re-assign them (e.g. `this.mySyncField = this.mySyncField`) to trigger an update * * @param onFieldChanged name of a callback function that will be called when the field is changed. * You can also pass in a function like so: syncField(myClass.prototype.myFunctionToBeCalled) * Note: if you return `false` from this function you'll prevent the field from being synced with other clients * (for example a networked color is sent as a number and may be converted to a color in the receiver again) * Parameters: (newValue, previousValue) */ export const syncField = function (onFieldChanged = null) { return function (target, _propertyKey) { let propertyKey = ""; if (typeof _propertyKey === "string") propertyKey = _propertyKey; else propertyKey = _propertyKey.name; let syncer = null; let fn = undefined; if (typeof onFieldChanged === "string") fn = target[onFieldChanged]; else if (typeof onFieldChanged === "function") { fn = onFieldChanged; } if (fn == undefined) { if (isDevEnvironment() || debug) { if (onFieldChanged != undefined) console.warn("syncField: no callback function found for property \"" + propertyKey + "\"", "\"" + onFieldChanged + "\""); } } const t = target; const internalAwake = t.__internalAwake; if (typeof internalAwake !== "function") { if (debug || isDevEnvironment()) console.error("@syncField can currently only used on Needle Engine Components, custom object of type \"" + target?.constructor?.name + "\" is not supported", target); return; } if (debug) console.log(propertyKey); const backingFieldName = Symbol(propertyKey); t.__internalAwake = function () { if (this[backingFieldName] !== undefined) { return; } this[backingFieldName] = this[propertyKey]; syncer = syncerHandler.getOrCreateSyncer(this); const desc = Object.getOwnPropertyDescriptor(this, propertyKey); if (desc?.set === undefined) { let invokingCallback = false; Object.defineProperty(this, propertyKey, { set: function (value) { const oldValue = this[backingFieldName]; this[backingFieldName] = value; // Prevent recursive calls when object is assigned in callback if (invokingCallback) { if (isDevEnvironment() || debug) console.warn("Recursive call detected", propertyKey); return; } invokingCallback = true; try { const valueChanged = testValueChanged(value, oldValue); if (debug) console.log("SyncField assignment", propertyKey, "changed?", valueChanged, value, fn); if (valueChanged) { const res = fn?.call(this, value, oldValue); if (res !== false) { getSyncer(this)?.notifyChanged(propertyKey, value); } } } finally { invokingCallback = false; } }, get: function () { return this[backingFieldName]; }, configurable: true, enumerable: true, }); } syncer?.init(this); internalAwake.call(this); }; const internalDestroy = t.__internalDestroy; t.__internalDestroy = function () { destroySyncer(this); internalDestroy.call(this); }; }; }; /** experimental - use syncField instead */ export const sync = function (_options) { return function (target, _propertyKey, descriptor) { // override awake const comp = target; let syncer; const internalAwake = comp.__internalAwake.bind(comp); comp.__internalAwake = function () { if (!this.guid) { internalAwake?.call(this); return; } internalAwake(); syncer = syncerHandler.getOrCreateSyncer(this); syncer?.init(this); }; // inject getter and setter if (!descriptor.get) { const previousSetter = descriptor.set; const $backingField = Symbol(_propertyKey); Object.defineProperty(target, _propertyKey, { set: function (value) { this[$backingField] = value; previousSetter?.call(this, value); }, get: function () { return this[$backingField]; } }); const newDescriptor = Object.getOwnPropertyDescriptor(target, _propertyKey); if (newDescriptor) { descriptor.set = newDescriptor.set; descriptor.get = newDescriptor.get; } } const setter = descriptor.set; const getter = descriptor.get; let previousValue = undefined; if (setter) { descriptor.set = function (value) { const valueChanged = false; const syncer = getSyncer(this); // test change if (syncer && comp.context && comp.context.connection?.isConnected) { testValueChanged(value, previousValue); } if (valueChanged) { // set the value previousValue = value; setter.call(this, value); syncer?.notifyChanged(_propertyKey, value); } }; } }; }; //# sourceMappingURL=engine_networking_auto.js.map