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.

380 lines (331 loc) 13.4 kB
import { isDevEnvironment } from "./debug/index.js"; import { INetworkConnection, SendQueue } from "./engine_networking_types.js"; import type { IComponent } from "./engine_types.js"; import { getParam } from "./engine_utils.js"; const debug = getParam("debugautosync"); const $syncerId = Symbol("syncerId"); class ComponentsSyncerManager { private _syncers: { [key: string]: ComponentPropertiesSyncer } = {}; getOrCreateSyncer(comp: IComponent): ComponentPropertiesSyncer | null { 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: ComponentPropertiesSyncer) { delete this._syncers[syncer[$syncerId]]; } } const syncerHandler = new ComponentsSyncerManager(); /** * Collects and bundles all changes in properties per component in a frame */ class ComponentPropertiesSyncer { comp: IComponent; constructor(comp: IComponent) { this.comp = comp; } // private getters: { [key: string]: Function } = {}; private hasChanges: boolean = false; private changedProperties: { [key: string]: any } = {}; get networkingKey(): string { return this.comp.guid; } /** is set to true in on receive call to avoid circular sending */ private _isReceiving: boolean = false; private _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: string, value: any) { if (this._isReceiving) return; if (debug) console.log("Property changed: " + propertyName, value); this.hasChanges = true; this.changedProperties[propertyName] = value; } private onHandleSending = () => { if (!this.hasChanges) return; this.hasChanges = false; const net = this.comp.context.connection as INetworkConnection 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]; } } private onHandleReceiving = (val: { guid: string, property: string, data: any }) => { 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): boolean { 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): ComponentPropertiesSyncer | null { 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]; } } export declare type SyncFieldOptions = { onPropertyChanged: Function, }; export declare type FieldChangedCallbackFn = (newValue: any, previousValue: any) => void | boolean | any; /** * Marks a field for automatic network synchronization across connected clients. * When a synced field changes, the new value is automatically broadcast to all users in the room. * * Primitives (string, number, boolean) sync automatically. * For arrays/objects, reassign to trigger sync: `this.myArray = this.myArray` * * @param onFieldChanged Optional callback when the field changes (locally or from network). * Return `false` to prevent syncing this change to others. * * @example Basic sync * ```ts * class MyComponent extends Behaviour { * @syncField() playerScore: number = 0; * } * ``` * @example With change callback * ```ts * class MyComponent extends Behaviour { * @syncField("onHealthChanged") health: number = 100; * * onHealthChanged(newValue: number, oldValue: number) { * console.log(`Health: ${oldValue} → ${newValue}`); * } * } * ``` * @example Preventing sync (one-way) * ```ts * class MyComponent extends Behaviour { * @syncField(function(newVal, oldVal) { * // Process incoming value but don't sync our changes * return false; * }) serverControlled: string = ""; * } * ``` * @see {@link serializable} for editor serialization * @link https://engine.needle.tools/docs/how-to-guides/networking/ */ export const syncField = function (onFieldChanged: string | FieldChangedCallbackFn | undefined | null = null) { return function (target: any, _propertyKey: string | { name: string }) { let propertyKey = ""; if (typeof _propertyKey === "string") propertyKey = _propertyKey; else propertyKey = _propertyKey.name; let syncer: ComponentPropertiesSyncer | null = null; let fn: Function | undefined = 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); } } } export declare type SyncOptions = { key?: string, fieldName?: string, }; /** experimental - use syncField instead */ export const sync = function (_options?: SyncOptions) { return function <T>(target: any, _propertyKey: string, descriptor: PropertyDescriptor) { // override awake const comp = target as IComponent; let syncer: ComponentPropertiesSyncer | null; 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: T | undefined = undefined; if (setter) { descriptor.set = function (value: T) { 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); } }; } } };