@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.
353 lines (304 loc) • 13.2 kB
text/typescript
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;
/**
* **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: 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);
}
};
}
}
};