/**
Object ID, consisting of class name and UUID.
*/
export class ID {
constructor(className,id) {
/** Class name
* @type {string}
*/
this.className = className;
/** Identifier (UUID)
* @type {string}
*/
this.id = id;
}
/** class name + ' ' + id
* @returns {string}
*/
toString() {
return this.className+" "+this.id;
}
}
/**
* Rotation
* @typedef {Object} Rotation
* @prop {number} [x=0]
* @prop {number} [y=1]
* @prop {number} [z=0]
*/
export class Rotation {
constructor(){
this.x=0;
this.y=1;
this.z=0;
}
}
/**
* Quaternion
* @typedef {Object} Quaternion
* @prop {number|null} [x]
* @prop {number|null} [y]
* @prop {number|null} [z]
* @prop {number|null} [w]
*/
export class Quaternion {
constructor(){
this.x=null;
this.y=null;
this.z=null;
this.w=null;
}
}
/**
Point in space, x, y, z
*/
export class Point {
constructor(){
/** @type {number} */
this.x=0;
/** @type {number} */
this.y=0;
/** @type {number} */
this.z=0;
}
}
/**
Currently active animation of an object.
*/
export class Animation {
constructor() {
this.name=null;
this.loop=false;
this.speedRatio=1;
}
}
/**
Welcome message received from the server when entering a world.
*/
export class Welcome {
constructor() {
/** TODO classname first, e.g. client.User.fields */
this.client = null;
/** @type {Array.<VRObject>} */
this.permanents = [];
}
}
/**
Activate message received when de/activating an object
*/
export class Activate {
constructor() {
/** @type {string} */
this.className = null;
/** @type {string} */
this.id = null;
/** @type {boolean} */
this.active = false;
}
}
/**
Basic VRObject, has the same properties as server counterpart.
*/
export class VRObject extends ID {
constructor() {
super();
/** Id, equal on server and all instances
* @type {string}
*/
this.id = null;
/** Position, Point
* @type {Point}
*/
this.position = null;
/** Rotation
* @type {Rotation|null}
*/
this.rotation = null;
/** Scale, Point
* @type {Point|null}
*/
this.scale = null;
/** Default false, permanent objects remain in the scene forever
* @type {boolean}
*/
this.permanent = false;
/** Everything created by guest client is by default temporary
* @type {boolean}
*/
this.temporary = null;
/** URL of 3D mesh
* @type {string|null}
*/
this.mesh = null;
/** Active i.e. online users
* @type {boolean}
*/
this.active = false;
/** Name of the animation that is currently active
* @type {string|null}
*/
this.animation = null;
/** URL of dynamically loaded script
* @type {string|null}
*/
this.script = null;
/** Custom properties of an object - shared transient object
* @type {Object}
*/
this.properties = null;
//this.children = []; // CHECKME
/** Event listeners. Typically world manager listens to changes, and moves objects around. */
this.listeners = [];
/** Load listeners, functions that trigger after the mesh or script is loaded. Managed by WorldManager. CHECKME SoC */
this.loadListeners = [];
/** Internal, set by WorldManager after the mesh/script has loaded. */
this._isLoaded = false;
/** Handy reference to VRSpace instance */
this.VRSPACE = null;
/** Server-side class name
* @type {string}
*/
this.className = 'VRObject';
}
/**
Add a change listener to the object.
*/
addListener(listener) {
var pos = this.listeners.indexOf(listener);
if ( pos < 0 ) {
this.listeners.push(listener);
}
}
/**
Add a load listener function to the object. Triggers immediatelly if mesh/script has already loaded (_isLoaded is true).
*/
addLoadListener(listener) {
this.loadListeners.push(listener);
if ( this._isLoaded ) {
listener(this);
}
}
/**
Remove the listener.
*/
removeListener(listener) {
var pos = this.listeners.indexOf(listener);
if ( pos > -1 ) {
this.listeners.splice(pos,1);
}
}
/**
Remove a load listener.
*/
removeLoadListener(listener) {
var pos = this.loadListeners.indexOf(listener);
if ( pos > -1 ) {
this.loadListeners.splice(pos,1);
}
}
/**
Called when server sends notification that the object has changed.
Notifies all listeners of object and changes.
*/
notifyListeners(changes) {
for ( var i = 0; i < this.listeners.length; i++ ) {
this.listeners[i](this,changes);
}
}
/** Triggers all load listeners */
notifyLoadListeners() {
this._isLoaded = true;
this.loadListeners.forEach(l=>l(this));
}
/** Publish the object to the server. Can be used only on new objects. */
publish() {
if ( ! this.VRSPACE ) {
throw "the object is not shared yet";
}
let event = new VREvent( this );
for ( var key in this ) {
if ( key !== 'id' && key !== 'VRSPACE' ) {
event.changes[key] = this[key];
}
}
// FIXME: TypeError: cyclic object value
let json = JSON.stringify(event);
this.VRSPACE.log(json);
this.VRSPACE.send(json);
}
/**
* Returns ID of this VRObject
* @returns {ID}
*/
getID() {
return new ID(this.className, this.id);
}
/**
* Executedwhile handling network event. Empty implementation, overridden by EventRouter.
*/
positionChanged() {}
/**
* Executedwhile handling network event. Empty implementation, overridden by EventRouter.
*/
rotationChanged() {}
/**
* Executedwhile handling network event. Empty implementation, overridden by EventRouter.
*/
scaleChanged() {}
}
/**
Scene properties, same as server counterpart.
*/
export class SceneProperties{
constructor() {
/** Visibility range, default 2000
* @type {number}
*/
this.range = 2000;
/** Movement resolution, default 10
* @type {number}
*/
this.resolution = 10;
/** Maximum size, default 1000
* @type {number}
*/
this.size = 1000;
/** Invalidation timeout in ms, default 30000
* @type {number}
*/
this.timeout = 30000;
}
}
/**
Representation of a client (user, bot, remote server...).
@extends VRObject
*/
export class Client extends VRObject {
constructor() {
super();
/** Client name, must be unique
* @type {string}
*/
this.name = null;
/** Scene properties
* @type {SceneProperties}
*/
this.sceneProperties = null; // CHECKME private - should be declared?
/** Private tokens, map of strings */
this.tokens = null;
/** Server-side class name
* @type {string}
*/
this.className = 'Client';
/** true if the client has an avatar
* @type {boolean}
*/
this.hasAvatar = true;
/** avatar picture url
* @type {string|null}
*/
this.picture = null;
}
/** Handy function, returns name if not null, else class and id */
getNameOrId() {
if ( this.name ) {
return this.name;
}
return this.className+" "+this.id;
}
}
/**
Representation of a user.
@extends Client
*/
export class User extends Client {
constructor() {
super();
/** Does this client have humanoid avatar, default true
* @type {boolean}
*/
this.humanoid = true;
/** Does this client have video avatar, default false
* @type {boolean}
*/
this.video = false;
/** Left arm position
* @type {Point}
*/
this.leftArmPos = { x: null, y: null, z: null };
/** Right arm position
* @type {Point}
*/
this.rightArmPos = { x: null, y: null, z: null };
/** Left arm rotation, quaternion
* @type {Quaternion}
*/
this.leftArmRot = { x: null, y: null, z: null, w: null };
/** Right arm rotation, quaternion
* @type {Quaternion}
*/
this.rightArmRot = { x: null, y: null, z: null, w: null };
/** User height, default 1.8
* @type {number}
*/
this.userHeight = 1.8;
/** Server-side class name
* @type {string}
*/
this.className = 'User';
}
}
export class RemoteServer extends Client {
constructor() {
super();
this.url = null;
this.thumbnail = null;
this.humanoid = false;
this.hasAvatar = false;
this.className = 'RemoteServer';
}
}
/**
See server side counterpart.
@extends Client
*/
export class EventRecorder extends User {
constructor() {
super();
/** Server-side class name */
this.className = 'EventRecorder';
}
}
/**
Robot base class, useful for chatbots.
@extends User
*/
export class Bot extends User {
constructor() {
super();
this.gender = null;
this.lang = null;
/** Server-side class name */
this.className = 'Bot';
}
}
export class BotLibre extends Bot {
constructor() {
super();
/** Server-side class name */
this.className = 'BotLibre';
}
}
export class OllamaBot extends Bot {
constructor() {
super();
/** Server-side class name */
this.className = 'OllamaBot';
}
}
export class Terrain extends VRObject {
constructor() {
super();
this.className = 'Terrain';
this.diffuseColor = null;
this.diffuseTexture = null;
this.emissiveColor = null;
this.specularColor = null;
this.points = null;
}
}
export class Content {
constructor() {
/** @type {string|null} */
this.fileName = null;
/** @type {string|null} */
this.contentType = null;
/** @type {number|null} */
this.length = null;
}
}
export class VRFile extends VRObject {
constructor() {
super();
this.className = 'VRFile';
/** @type {Content|null} */
this.content = null;
}
}
export class Background extends VRObject {
constructor() {
super();
this.className = 'Background';
this.texture = null;
this.ambientIntensity = 0;
}
}
export class Game extends VRObject {
constructor() {
super();
this.className = 'Game';
this.name = null;
this.numberOfPlayers=0;
this.status=null;
this.players=[];
}
}
/**
An event that happened to an object.
@param obj VRObject instance
@param changes optional object encapsulating changes to the object (field:value)
*/
export class VREvent {
constructor(obj, changes) {
/** VRObject that has changed */
this.object=new Object();
// obufscators get in the way of this:
//this.object[obj.constructor.name]=obj.id;
this.object[obj.className]=obj.id;
/** Changes to the object */
if ( changes ) {
this.changes = changes;
} else {
this.changes=new Object();
}
}
}
/**
A scene event - addition or removal of some objects, typically users.
An object is either added or removed, the other value is null.
*/
export class SceneEvent {
constructor(scene,className,objectId,added,removed) {
/** Class name of object added/removed
* @type {string}
*/
this.className = className;
/** Id of added/removed object
* @type {ID}
*/
this.objectId = objectId;
/** Added object
* @type {VRObject|null}
*/
this.added = added;
/** Removed object
* @type {VRObject|null}
*/
this.removed = removed;
/** New scene
* @type {Map<String, Object>}
*/
this.scene = scene;
}
}
/**
* Streaming session data, used to match the client avatar or other mesh to the video/audio stream.
*/
export class SessionData {
/**
* @param {String} json string representation of this object (passed along connection as user data)
*/
constructor(json) {
/** Client id, long
* @type {number}
*/
this.clientId = null;
/** Session name, matches either world name for public world, or world token for private world
* @type {string}
*/
this.name = null;
/** Session type - 'main' or 'screen'
* @type {string}
*/
this.type = null;
JSON.parse(json, (key,value)=>{
this[key] = value;
return value;
});
}
}
export class UserGroup {
constructor() {
this.id = null;
this.name = null;
this.isPublic = null;
this.temporary = null;
this.direct = null;
}
}
export class GroupMember {
constructor() {
this.id = null;
/** @type {UserGroup} */
this.group = null;
/** @type {Client} */
this.client = null;
this.pendingInvite = null;
this.pendingRequest = null;
/** @type {Client} */
this.sponsor = null;
this.lastUpdate = null;
}
}
export class GroupMessage {
constructor() {
/** @type {String} */
this.id = null;
/** @type {Client} */
this.from = null;
/** @type {UserGroup} */
this.group = null;
/** @type {string} */
this.content = null;
/** @type {string} */
this.link = null;
// CHECKME worldId used only for world invitations/shares
/** @type {Date} */
this.timestamp = null;
/** @type {boolean} */
this.local = null;
/** @type {Array.<Content>} */
this.attachments = [];
}
}
/** Notification from a UserGroup */
export class GroupEvent {
constructor() {
/** @type {GroupMessage} */
this.message = null;
/** @type {GroupMember} */
this.invite = null;
/** @type {GroupMember} */
this.ask = null;
/** @type {GroupMember} */
this.allowed = null;
/** @type {GroupMessage} */
this.attachment = null;
}
}
/**
Main client API class, no external dependencies.
Provides send methods to send messages to the server, to be distributed to other clients.
Listeners receive remote events.
*/
export class VRSpace {
constructor() {
/** Underlying websocket */
this.ws = null;
/** Representation of own Client, available once the connection is established
* @type {User}
*/
this.me = null;
/** Map containing all objects in the scene
* @type {Map<string, VRObject>}
*/
this.scene = new Map();
/**
* Connection URL, set once connection is established
* @type {string}
*/
this.url = null;
/** Debug logging, default false */
this.debug = false;
/** Reconnect automatically, experimental */
this.autoReconnect = true;
this.reconnecting = false;
this.retryTimer = null;
this.connectionListeners = [];
this.dataListeners = [];
this.sceneListeners = [];
this.activationListeners = [];
this.welcomeListeners = [];
this.errorListeners = [];
this.groupListeners = [];
/** Listeners to responses to commands, processed one at a time. */
this.responseListeners = [];
this.sharedClasses = { ID, Rotation, Point, VRObject, SceneProperties, Client, User, RemoteServer, VREvent, SceneEvent, EventRecorder, Bot, BotLibre, OllamaBot, Terrain, VRFile, Game, Background };
//this.pingTimerId = 0;
// exposing each class
for( var c in this.sharedClasses ) {
this[c] = this.sharedClasses[c];
}
this.messageHandlers = {
object:message=>this.handleEvent(message),
Add:message=>this.handleAdd(message.Add),
Remove:message=>this.handleRemove(message.Remove),
Activate:message=>this.handleActivate(message.Activate),
ERROR:message=>this.handleError(message.ERROR),
Welcome:message=>this.handleWelcome(message.Welcome),
response:message=>this.handleResponse(message.response),
GroupEvent:message=>this.handleGroupEvent(message)
}
}
log( msg ) {
if ( this.debug ) {
console.log(msg);
}
}
/* Used internally to add a listener */
addListener(array, callback) {
if ( typeof callback == 'function' || typeof callback == 'object') {
if ( array.includes(callback) ) {
console.error("Listener already added");
} else {
array.push(callback);
}
}
return callback;
}
/* Used internally to remove a listener */
removeListener(array, listener) {
var pos = array.indexOf(listener);
if ( pos > -1 ) {
array.splice(pos,1);
}
}
/**
Add a connection listener that gets notified when connection is activated/broken.
Callback is passed boolean argument indicating connection state.
*/
addConnectionListener(callback) {
return this.addListener( this.connectionListeners, callback);
}
removeConnectionListener(callback) {
this.removeListener(this.connectionListeners, callback);
}
/** Add a data listener that receives everything from the server (JSON string argument) */
addDataListener(callback) {
return this.addListener( this.dataListeners, callback);
}
/**
* @callback sceneCallback
* @param {SceneEvent} event
*/
/**
Add a scene listener that gets notified when the scene is changed.
Scene listeners receive SceneEvent argument for each change.
@param {sceneCallback} callback
*/
addSceneListener(callback) {
return this.addListener( this.sceneListeners, callback );
}
/**
Remove a scene listener.
*/
removeSceneListener(callback) {
this.removeListener( this.sceneListeners, callback );
}
/**
Add an activation listener, that gets called when active property of a VRObject changes.
Lister needs to update event model then, i.e. start/stop listening to events from the object.
@param {*} callback
*/
addActivationListener(callback) {
return this.addListener( this.activationListeners, callback );
}
/**
Remove an activation listener.
*/
removeActivationListener(callback) {
this.removeListener( this.activationListeners, callback );
}
/**
Add a Welcome listener, notified when entering a world.
The listener receives Welcome object.
*/
addWelcomeListener(callback) {
return this.addListener( this.welcomeListeners, callback);
}
/**
Remove welcome listener
@param callback listener to remove
*/
removeWelcomeListener(callback) {
this.removeListener( this.welcomeListeners, callback);
}
/**
Add error listener, notified when server sends error notifications.
Error listener is passed the string containing the server error message, e.g. java exception.
*/
addErrorListener(callback) {
return this.addListener( this.errorListeners, callback);
}
/**
Remove error listener
@param callback listener to remove
*/
removeErrorListener(callback) {
this.removeListener( this.errorListeners, callback);
}
/**
Add a group listener, notified when entering a world.
The listener receives Welcome object.
@param {function(GroupEvent)} callback
*/
addGroupListener(callback) {
return this.addListener( this.groupListeners, callback);
}
/**
Remove group listener
@param callback listener to remove
*/
removeGroupListener(callback) {
this.removeListener( this.groupListeners, callback);
}
/**
Return the current scene, optionally filtered
@param filter string to match current members, usually class name, or function that takes VRObject as argument
@return {Map<string, VRObject>} scene
*/
getScene( filter ) {
if ( typeof filter === 'undefined') {
return this.scene;
} else if ( typeof filter === 'string') {
var ret = new Map();
for ( const [key,value] of this.scene ) {
if ( key.startsWith(filter) ) {
ret.set(key,value);
}
}
return ret;
} else if ( typeof filter === 'function') {
var ret = new Map();
for ( const [key,value] of this.scene ) {
if ( filter(value) ) {
ret.set(key,value);
}
}
return ret;
}
}
/**
* @private
*/
defaultWebsocketUrl() {
let url = window.location.href;
this.log("This href "+url);
let start = url.indexOf('/');
let protocol = url.substring(0,start);
let webSocketProtocol = 'ws';
if ( protocol == 'https:' ) {
webSocketProtocol = 'wss';
}
let end = url.indexOf('/', start+2);
url = webSocketProtocol+':'+url.substring(start,end)+'/vrspace/client'; // ws://localhost:8080/vrspace
return url;
}
/**
Connect to the server, attach connection listeners and data listeners to the websocket.
@param {string} [url] optional websocket url, defaults to /vrspace/client on the same server
@returns {Promise} promise resolved once the connection is successful
*/
connect(url) {
if ( ! url ) {
url = this.defaultWebsocketUrl();
}
if ( this.isConnected() ) {
throw "Already connected to "+this.url;
}
this.url = url;
this.log("Connecting to "+url);
this.ws = new WebSocket(url);
return new Promise( (resolve, reject) => {
this.ws.onopen = () => {
this.connectionListeners.forEach((listener)=>listener(true));
resolve();
}
this.ws.onclose = (event) => {
// TODO handle websocket error codes, reconnect if possible
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event
// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
// code 1006 = no close frame, server termination (should be 1012)
// code 1008 = http session expired
// while reconnecting, if connection fails, we get onclose event
if (!this.retryTimer) {
//if (this.debug) {
console.log("WebSocket closed", event);
//}
const shouldReconnect = this.autoReconnect && event.code != 1008;
this.connectionListeners.forEach((listener)=>{
listener(false, shouldReconnect);
this.me = null;
});
if (shouldReconnect) {
this.reconnect();
}
}
}
this.ws.onmessage = (data) => {
this.receive(data.data);
this.dataListeners.forEach((listener)=>listener(data.data));
}
this.ws.onerror = (err) => {
//if ( this.debug ) {
//console.log("WebSocket error",err);
//}
reject(err);
}
});
}
isConnected() {
return this.ws && this.ws.readyState == this.ws.OPEN;
}
reconnect(interval=5000, retries=10) {
if ( this.retryTimer ) {
throw "Already retrying";
}
let retry = 0;
this.retryTimer = setInterval( () => {
if ( ++ retry >= retries ) {
clearInterval(this.retryTimer);
this.retryTimer = null;
console.error("Failed to reconnect after "+retries+" retries");
this.reconnecting = false;
this.connectionListeners.forEach((listener)=>{
listener(false, false);
});
} else if (!this.reconnecting) {
this.reconnecting = true;
this.connect(this.url).then(()=>{
clearInterval(this.retryTimer);
this.retryTimer = null;
this.reconnecting = false;
console.log("Reconnect attempt "+retry+" succeeded");
}).catch(err=>{
console.log("Reconnect attempt "+retry+" failed", err);
this.reconnecting = false;
});
}
}, interval);
}
/** Disconnect, notify connection listeners */
disconnect() {
if (this.ws != null) {
this.ws.close();
}
this.log("Disconnected");
}
/** Convert a vector to json string
@param vec object having x,y,z properties
*/
stringifyVector(vec) {
return '{"x":'+vec.x+',"y":'+vec.y+',"z":'+vec.z+'}';
}
/** Convert a quaternion to json string
@param vec object having x,y,z,w properties
*/
stringifyQuaternion(quat) {
return '{"x":'+quat.x+',"y":'+quat.y+',"z":'+quat.z+',"w":'+quat.w+'}';
}
/** Convert a key/value pair to json string.
FIXME improperly stringifies objects having properties x, _x, or w. Properties other than x,y,z,w will be ignored.
See stringifyVector and stringifyQuaternion.
This is essentially workaround for bablyon types, e.g. Vector3, that have _x, _y, _z properties.
@param field name of the field
@param value string, object or number to convert
*/
stringifyPair( field, value ) {
if ( typeof value == "string") {
return '"'+field+'":"'+value+'"';
} else if ( typeof value == 'object') {
if ( value == null ) {
return '"'+field+'":null';
} else if (
(value.hasOwnProperty('x') || value.hasOwnProperty('_x')) &&
(value.hasOwnProperty('y') || value.hasOwnProperty('_y')) &&
(value.hasOwnProperty('z') || value.hasOwnProperty('_z'))
) {
if(value.hasOwnProperty('w')) {
return '"'+field+'":'+this.stringifyQuaternion(value);
} else {
return '"'+field+'":'+this.stringifyVector(value);
}
} else {
// assuming custom object
return '"'+field+'":'+JSON.stringify(value);
}
} else if ( typeof value == 'number') {
return '"'+field+'":'+value;
} else if ( typeof value == 'boolean') {
return '"'+field+'":'+value;
} else {
console.log("Unsupported datatype "+typeof value+", ignored user event "+field+"="+value);
return '';
}
}
/** Create a local field of an object existing on the server FIXME Obsolete */
createField(className, fieldName, callback) {
// TODO: use class metadata
this.call('{"command":{"Describe":{"className":"'+className+'"}}}',(response) => {
var ret = null;
for ( var field in response ) {
if ( field === fieldName ) {
var javaType = response[field];
if ( javaType === 'String' ) {
ret = '';
} else if ( javaType == 'Long' ) {
ret = 0;
} else if ( javaType == 'Double' ) {
ret = 0.0;
} else {
//ret = (Function('return new ' + javaType))();
ret = new this.newInstance(className);
}
break;
}
}
callback(ret);
});
}
/**
Share an object.
@param {VRObject} obj the new VRObject, containing all properties
@param {string|undefined} [className] optional class name to create, defaults to obj.className if exists, otherwise VRObject
@param {boolean} [temporary] Create temporary object. Defaults to true for scripts.
@returns {Promise<VRObject>} Promise with the created VRObject instance
*/
async createSharedObject( obj, className, temporary ) {
if ( ! className ) {
if ( obj.className ) {
className = obj.className;
} else {
className = 'VRObject';
}
}
if ( temporary ) {
obj.temporary = true;
} else if ( obj.script ) {
obj.temporary = true;
}
let json = JSON.stringify(obj);
this.log(json);
return new Promise( (resolve, reject) => {
// response to command contains object ID
this.call('{"command":{"Add":{"objects":[{"' + className + '":'+json+'}]}}}', (response) => {
this.log("Response:", response);
var objectId = response[0][className];
const id = new ID(className,objectId);
this.log("Created object:"+ objectId);
// by now the object is already in the scene, since Add message preceeded the response
var ret = this.scene.get(id.toString());
resolve(ret);
});
});
}
/**
Delete a shared object.
@param {ID} obj to be removed from the server
@param {*} [callback] optional, called after removal from the server
*/
deleteSharedObject( obj, callback ) {
this.call('{"command":{"Remove":{"objects":[{"' + obj.className + '":"'+obj.id+'"}]}}}', (response) => {
if ( callback ) {
callback(obj);
}
});
}
/**
Send notification of own property changes
@param {string} field name of member variable that has changed
@param {*} value new field value
*/
sendMy(field,value) {
if ( this.me != null) {
this.send('{"object":{"'+this.me.className+'":"'+this.me.id+'"},"changes":{'+this.stringifyPair(field,value)+'}}');
} else {
this.log("No my ID yet, ignored user event "+field+"="+value);
}
}
/**
Send a command to the server
@param {string} command to execute
@param {Object} args optional object with command arguments
*/
sendCommand( command, args ) {
if ( args ) {
this.send('{"command":{"'+command+'":'+JSON.stringify(args)+'}}');
} else {
this.send('{"command":{"'+command+'":{}}}');
}
}
/**
Send a command to the server
@param {string} command to execute
@param callback function that's called with command return value
*/
callCommand( command, callback ) {
this.call('{"command":{"'+command+'":{}}}', callback);
}
/**
Send a command to the server
@param {string} command to execute
*/
async callCommandAsync( command ) {
return this.callAsync('{"command":{"'+command+'":{}}}');
}
/**
* Set a client token e.g. required to enter a world
* @param {string} name token name
* @param {string} value token value
*/
setToken( name, value ) {
this.sendCommand( "SetToken", {name:name, value:value});
}
/**
* Enter a world, optionally with a token (that may be required for private worlds).
* The server sends Welcome message, that's supposed to be processed with Welcome listeners.
*
* @param {string} world Name of the world to enter
* @param {string} [token] optional token value
*/
enter( world, token ) {
if ( token ) {
this.sendCommand("Enter", { world: world, token: token });
} else {
this.sendCommand("Enter", { world: world });
}
}
/**
* Enter a world, optionally with a token (that may be required for private worlds).
* The servers sends back Welcome response message, that is resolved in Promise.
*
* @param {string} world Name of the world to enter
* @param {string} [token] optional token value
* @returns {Promise<Welcome>} promise with the welcome message
*/
enterAsync( world, token ) {
let command = '{"command":{"Enter":{"async":false, "world":"'+world+'"}}}';
if ( token ) {
command = '{"command":{"Enter":{"async":false, "world":"'+world+'", "token":"'+token+'"}}}';
}
return new Promise( (resolve, reject) => {
this.call(command, (response) => {
resolve(response);
});
});
}
/**
* Start the session: sends Session command to the server
* @returns {Promise} resolves when server responds
*/
async sessionStart() {
return this.callCommandAsync("Session");
}
/**
Send changes to an object
@param obj VRObject that changes
@param changes array containing field/value pairs
*/
sendChanges(obj, changes) {
if ( ! changes || changes.length == 0 ) {
return;
}
var index = 0;
var msg = '{"object":{"'+obj.className+'":"'+obj.id+'"},"changes":{';
changes.forEach((change) => {
msg += this.stringifyPair(change.field,change.value);
index++;
if ( index < changes.length ) {
msg += ',';
}
});
msg += '}}';
this.send(msg);
}
/**
Send changes to an object
@param {ID} obj VRObject that changes
@param {Object} changes object containing changed fields
*/
sendEvent(obj, changes) {
if ( ! changes || changes.length == 0 ) {
return;
}
var index = 0;
var msg = '{"object":{"'+obj.className+'":"'+obj.id+'"},"changes":{';
for ( var change in changes ){
msg += this.stringifyPair(change,changes[change]);
index++;
msg += ',';
};
msg = msg.substring(0,msg.length-1)+'}}';
this.send(msg);
}
/**
Send changes to own avatar
@param changes array with field/value pairs
*/
sendMyChanges(changes) {
if ( this.me != null) {
this.sendChanges(this.me, changes);
} else {
this.log("No my ID yet, user event ignored:");
this.log(changes);
//throw "No my ID yet, user event ignored:";
}
}
/**
Send changes to own avatar
@param {Object} changes object containing changed fields
*/
sendMyEvent(changes) {
if ( this.me != null) {
this.sendEvent(this.me, changes);
} else {
this.log("No my ID yet, user event ignored:");
this.log(changes);
throw "No my ID yet, user event ignored:";
}
}
/*
Send a message, internally called from other send methods
*/
send(message) {
this.log("Sending message: "+message);
this.ws.send(message);
}
/**
Perform a synchronous call, used internally by callCommand etc.
Enqueues callback, and executes it when the response to command arrives.
Successful call chaining depends on server executing them sequentially.
REST API calls are better option for synchronous calls.
@param {string} message JSON string to send
@param {*} callback function to execute upon receiving the response
*/
async call( message, callback ) {
this.responseListeners.push(callback);
this.send(message);
}
/**
* Perfom a synchronous call.
* @param {string} message JSON string to send
* @returns {Promise} resolves with response from the server
*/
callAsync(message) {
return new Promise((resolve,reject)=>this.call(message, (response)=>resolve(response)));
}
/**
Factory method
@param className shared class name
@returns new shared object instance
*/
newInstance(className) {
if ( this.sharedClasses[className] ) {
return new this.sharedClasses[className];
} else {
console.log("Unknown object type: "+className);
return null;
}
}
/* Add an object to the scene, used internally */
addToScene(className, object) {
var classInstance = this.newInstance(className);
if ( classInstance ) {
Object.assign(classInstance,object);
classInstance.VRSPACE = this;
var id = new ID(className,object.id);
this.scene.set(id.toString(), classInstance);
// notify listeners
const e = new SceneEvent(this.scene, className, id, classInstance, null);
this.sceneListeners.forEach((listener) => listener(e));
}
}
/* Add the object, used internally */
addObject(obj) {
var className = Object.keys(obj)[0];
var object = Object.values(obj)[0];
this.addToScene(className, object);
}
/** Remove object, used internally
* @param {object} objectId object taken from Remove command, e.g. {User:123}
*/
removeObject(objectId) {
const id = new ID(Object.keys(objectId)[0],Object.values(objectId)[0]);
this.removeByID(id);
}
/**
* Remove object from the scene and notify listeners
* @param {ID} id
*/
removeByID(id) {
const obj = this.scene.get(id.toString());
const deleted = this.scene.delete(id.toString());
this.log("deleted "+this.scene.size+" "+id+":"+deleted);
// notify listeners
const e = new SceneEvent(this.scene, id.className, id, null, obj);
this.sceneListeners.forEach((listener) => listener(e));
}
/**
Called when a message is received from the server. JSON message is converted to an object,
then depending on object type, forwarded to one of this.messageHandlers.
@param {String} message text message from the server over the websocket
*/
receive(message) {
this.log("Received: "+message);
let obj = JSON.parse(message);
let handlerName = Object.keys(obj)[0];
try {
if ( Object.hasOwn(this.messageHandlers,handlerName) ) {
this.messageHandlers[handlerName](obj);
} else {
console.error("ERROR: unknown message type", message);
}
} catch (exception) {
console.error("ERROR processing message "+message, exception);
}
}
/**
* Handle event of a shared VRObject: find the object in the scene, apply changes, notify listeners.
* @param {VREvent} message containing object id and changes
*/
handleEvent(message){
var id = new ID(Object.keys(message.object)[0],Object.values(message.object)[0]);
this.log("processing changes on "+id);
if ( this.scene.has(id.toString())) {
var object = this.scene.get(id.toString());
Object.assign(object,message.changes);
object.notifyListeners(message.changes);
} else {
this.log("Unknown object "+id);
}
}
/**
* Handle Add message: add every object to the scene, and notify listeners. Calls addObject.
* @param {Add} add Add command containing addedd objects
*/
handleAdd(add){
for ( let i=0; i< add.objects.length; i++ ) {
// this.log("adding "+i+":"+obj);
this.addObject(add.objects[i]);
}
this.log("added "+add.objects.length+" scene size "+this.scene.size);
}
/**
* Handle Remove message: remove every object from the scene, and notify listeners. Calls removeObject.
* @param {Remove} remove Remove command containing list of object IDs to remove
*/
handleRemove(remove){
for ( let i=0; i< remove.objects.length; i++ ) {
this.removeObject(remove.objects[i]);
}
}
/**
* Handle Activate message: update the object and call listeners.
* @param {Activate} activate
*/
handleActivate(activate){
let obj = this.scene.get(new ID(activate.className, activate.id).toString());
if (obj) {
obj.active = activate.active;
this.activationListeners.forEach((listener)=>listener(obj));
} else {
console.error("Activating unknown object: ",activate);
}
}
/**
* Handle server error: log the error, and notify error listeners.
* @param {object} error object containing error message received from the server
*/
handleError(error){
this.log(error);
this.errorListeners.forEach((listener)=>listener(error));
}
/**
* Handle Welcome message: create own user object, and notify welcome listeners. Adds all permanent objects to the scene.
* @param {Welcome} welcome the Welcome message.
*/
handleWelcome(welcome){
//console.log("Welcome",welcome);
if ( ! this.me ) {
// FIXME: Uncaught TypeError: Cannot assign to read only property of function class
let client = new User();
this.me = Object.assign(client,welcome.client.User);
//console.log("ME: ", this.me);
}
this.welcomeListeners.forEach((listener)=>listener(welcome));
if ( welcome.permanents ) {
welcome.permanents.forEach( o => this.addObject(o));
}
}
/**
* Handle response to command: if responseListener is installed, execute it with the message, ignore otherwise.
* @param {object} response object containing response to the command, can be anything, depending on the command.
*/
handleResponse(response){
this.log("Response to command");
if ( this.responseListeners.length > 0 ) {
let callback = this.responseListeners.shift();
// CHECKME: try/catch?
callback(response);
}
}
/**
* Handle a group event, simply forward the event to all groupListeners.
* @param {GroupEvent} event
*/
handleGroupEvent(event) {
this.groupListeners.forEach(l=>l(event.GroupEvent));
}
/**
* Experimental. Executes StreamingSession start command on the server that returns session token,
* the executes callback, passing the token to it
*/
async startStreaming( callback ) {
return new Promise( (resolve, reject) => {
this.call('{"command":{"StreamingSession":{"action":"start"}}}', (response) => {
resolve(response);
if ( callback ) {
callback(response);
}
});
});
}
/**
* Experimental. Executes StreamingSession stop command on the server.
* CHECKME Since the server manages streaming sessions anyway, this may not be needed at all.
*/
stopStreaming() {
this.sendCommand("StreamingSession", {action:"stop"});
}
}
export const VRSPACE = new VRSpace();