@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
JavaScript
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