@yantra-core/yantra
Version:
Yantra.gg Serverless Physics SDK for Real-time Multiplayer Game Development
548 lines (479 loc) • 17.4 kB
JavaScript
import onServerMessage from './lib/core/onServerMessage.js';
import createWorld from './lib/world/createWorld.js';
import removeWorld from './lib/world/removeWorld.js';
import create from './lib/state/create.js';
import set from './lib/state/set.js';
import destroy from './lib/state/destroy.js';
import autoscale from './lib/autoscale.js';
import applyForce from './lib/state/applyForce.js';
import setVelocity from './lib/state/setVelocity.js';
import update from './lib/state/update.js';
import clearAll from './lib/state/clearAll.js';
import setConfig from './lib/state/setConfig.js';
import listWorlds from './lib/world/listWorlds.js';
import setWorld from './lib/world/setWorld.js';
import getWorld from './lib/world/getWorld.js';
import updateWorld from './lib/world/updateWorld.js';
import minimist from 'minimist';
import ws from 'ws';
import path from 'path';
/**
* Creates and returns a new YantraClient instance.
*
* @function
* @param {Object} options - Configuration options for the client.
* @param {function} [options.onServerMessage] - Callback function to handle server messages.
* @param {string} [options.owner='AYYO-ALPHA-0'] - Identifier for the owner of the client.
* @param {string} [options.region] - The region for the client.
* @param {boolean} [options.worker=false] - Flag indicating whether to use a worker.
* @returns {YantraClient} - A new YantraClient instance.
*/
/**
* Represents a client for the Yantra serverless physics platform.
*
* @class
* @param {Object} options - Configuration options for the Yantra client.
* @param {function} [options.onServerMessage] - Callback function to handle server messages.
* @param {string} [options.owner='AYYO-ALPHA-0'] - Identifier for the owner of the client.
* @param {string} [options.region] - The region for the client.
* @param {boolean} [options.worker=false] - Flag indicating whether to use a worker.
*/
function YantraClient(options) {
let self = this;
this.options = options || {};
this.serverConnection = null;
this.connectAttempts = 0;
this.connected = false;
this.region = 'Washington_DC';
this.world = {};
this.cache = {};
this.pushStateCache = [];
options.worker = false;
if (options.onServerMessage) {
this._onServerMessage = options.onServerMessage;
}
if (options.owner) {
this.owner = options.owner;
} else {
// this.owner = 'AYYO-ALPHA-0';
console.log('Warning: No owner found.');
console.log('Please run `yantra login` to login or register to your account')
}
if (options.region) {
this.region = options.region;
}
if (options.etherspaceEndpoint) {
this.etherspaceEndpoint = options.etherspaceEndpoint;
}
if (options.worldConfig) {
this.worldConfig = options.worldConfig;
}
if (options.logger) {
this.log = options.logger;
} else {
this.log = console.log;
}
if (options.accessToken) {
this.accessToken = options.accessToken;
}
if (options.worker) {
this.useWorker = true;
this.worker = new Worker('./worker.js');
this.worker.onmessage = function (e) {
// run the default onServerMessage function on all messages
let snapshot = self.onServerMessage(e.data);
// if the optional onServerMessage callback is defined, call it
if (this._onServerMessage) {
this._onServerMessage(snapshot);
}
};
} else {
this.useWorker = false;
}
}
YantraClient.prototype.emitGamestateEvents = function emitGamestateEvents (snapshot) {
let self = this;
// iterates through entire incoming gamestate array and checks for EVENT_MESSAGE
snapshot.state.forEach(function iterateStates(state){
//
// PLAYER MOVEMENT / CONTROL INPUT EVENTS
//
if (state.type === 'PLAYER' && typeof state.controls === 'object' && Object.keys(state.controls).length > 0) {
// console.log('input event emit it', state);
self.emit('input', state);
}
//
// PLAYER_JOINED / PLAYER_LEFT events
//
if (state.type === 'EVENT_MESSAGE' && state.kind === 'PLAYER_JOINED') {
// This is bound to YantraClient.on('PLAYER_JOINED')
self.emit('PLAYER_JOINED', {
id: state.nickname, // should be state.target
type: 'PLAYER'
});
}
if (state.type === 'EVENT_MESSAGE' && state.kind === 'PLAYER_LEFT') {
// This is bound to YantraClient.on('PLAYER_JOINED')
self.emit('PLAYER_LEFT', {
id: state.nickname, // should be state.target
type: 'PLAYER'
});
}
//
// COLLISION EVENTS
//
if (state.type === 'EVENT_COLLISION') {
// This is bound to YantraClient.on('collision', fn)
self.emit('collision', state);
}
});
}
YantraClient.prototype.onServerMessage = onServerMessage;
/**
* Creates an entity with the provided state.
*
* @function
* @memberof YantraClient
* @param {Object} state - The initial state of the entity to be created.
*/
YantraClient.prototype.create = create; // currently acts as update / create, TODO: should throw error if id exists
YantraClient.prototype.set = set;
YantraClient.prototype.destroy = destroy;
YantraClient.prototype.config = setConfig;
/*
if (!inBrowser) {
}
*/
YantraClient.prototype.list = listWorlds;
/**
* Updates the state of an entity identified by `bodyId`.
*
* @function
* @memberof YantraClient
* @param {string|number} bodyId - The identifier of the body/entity to update.
* @param {Object} state - An object representing the new state of the entity.
*/
YantraClient.prototype.update = update;
/**
* Asynchronously auto-scales resources within a specified region for a given world.
*
* @function
* @async
* @memberof YantraClient
* @param {string} region - The region where resources should be auto-scaled.
* @param {string} owner - The owner identifier for the resources.
* @param {string|number} worldId - The identifier of the world to auto-scale resources for.
* @returns {Promise<void>} - A promise that resolves when the autoscale operation is complete.
*/
YantraClient.prototype.autoscale = autoscale;
/**
* Applies a specified force to an entity identified by `bodyId`.
*
* @function
* @memberof YantraClient
* @param {string|number} bodyId - The identifier of the body/entity to which the force is applied.
* @param {Object} force - An object representing the force to be applied.
*/
YantraClient.prototype.applyForce = applyForce;
/**
* Asynchronously sets the velocity of an entity identified by `bodyId`.
*
* @function
* @memberof YantraClient
* @param {string|number} bodyId - The identifier of the body/entity for which to set the velocity.
* @param {Object} velocity - An object representing the velocity to be set.
*/
YantraClient.prototype.setVelocity = setVelocity;
/**
* Assigns the `createWorld` function to the `YantraClient` prototype, making it available
* to all instances of `YantraClient`.
*
* The `createWorld` function is an asynchronous operation that manages
* the creation of a "world" in the Yantra serverless physics platform.
*
* @function
* @async
* @memberof YantraClient
* @instance
* @param {(string|number)} worldId - A unique identifier intended for the new world.
* @param {Object} worldConfig - An object containing configuration settings and properties for the new world.
*
* @example
* let worldId = "exampleWorld123";
* let worldConfig = {
* property1: "value1",
* property2: "value2"
* // ... other world settings ...
* };
*
* let yantraClientInstance = new YantraClient(options);
* yantraClientInstance.createWorld(worldId, worldConfig);
*/
YantraClient.prototype.createWorld = createWorld;
YantraClient.prototype.removeWorld = removeWorld;
YantraClient.prototype.setWorld = setWorld;
YantraClient.prototype.getWorld = getWorld;
YantraClient.prototype.updateWorld = updateWorld;
/**
* Asynchronously establishes a connection to a specified world.
*
* @async
* @function
* @memberof YantraClient
* @instance
* @param {string|Object} worldId - The identifier of the world to connect to, or an object containing the WebSocket connection string.
* @throws {Error} Throws an error if there's an issue establishing a WebSocket connection.
* @returns {Promise<YantraClient>} Resolves with the `YantraClient` instance once connected, or rejects with an error if the connection fails.
* @example
* // Connect using a worldId string
* client.connect('worldIdString')
* .then(client => {
* this.log('Connected to the world:', client);
* })
* .catch(error => {
* console.error('Connection error:', error);
* });
*
* // Connect using an object with wsConnectionString property
* client.connect({ wsConnectionString: 'wss://example.com' })
* .then(client => {
* this.log('Connected to the world:', client);
* })
* .catch(error => {
* console.error('Connection error:', error);
* });
*/
YantraClient.prototype.connect = async function (worldId) {
let self = this;
let wsConnectionString;
let env = 'dev';
// TODO: remove this from YantraClient class, no minimist required
// Remark: `process.env.YANTRA_ENV` is set in production to override connect to local websocket server
// This is to ensure low-latency, as the custom world code is run on the same host as the game server
// Parse command-line arguments
if (typeof process !== 'undefined') {
const argv = minimist(process.argv.slice(2));
// Check for the YANTRA_ENV value
const yantraEnv = argv.env || 'prod'; // Default to 'prod' if not provided
if (yantraEnv === 'cloud') {
this.log('YantraClient Cloud Mode Detected. Connecting to local websocket server.');
worldId = {
wsConnectionString: 'ws://127.0.0.1'
};
}
}
if (typeof worldId === 'undefined') {
throw new Error('worldId is required for YantraClient.connect(worldId)');
}
if (typeof worldId === 'object') {
wsConnectionString = worldId.wsConnectionString;
} else {
// Call into autoscaler to discover the websocket connection string
// This will either return an existing connection string, or create a new one
let world = await this.autoscale(this.region, this.owner, worldId, env)
this.worldConfig = world[0];
this.log(world.length, 'server candidate(s) found');
this.log('Using best available server:', JSON.stringify(this.worldConfig.processInfo, true, 2));
if (this.worldConfig.processInfo.room) {
this.worldConfig.room = this.worldConfig.processInfo.room; // legacy API
}
wsConnectionString = world[0].processInfo.wsConnectionString;
}
this.connectAttempts++;
if (this.serverConnection) {
this.disconnect();
}
let apiToken = null;
this.log('connecting... ' + wsConnectionString);
return new Promise((resolve, reject) => {
if (typeof window !== 'undefined') {
this.serverConnection = new WebSocket(wsConnectionString);
} else {
this.serverConnection = new ws(wsConnectionString);
}
this.serverConnection.onopen = (event) => {
this._onOpen.bind(this, wsConnectionString)(event);
resolve(this); // Resolve the promise once the connection is opened
};
this.serverConnection.onerror = (event) => {
this._onError.bind(this, wsConnectionString)(event);
reject(new Error('WebSocket connection error')); // Reject the promise on error
};
this.serverConnection.onclose = this._onClose.bind(this, wsConnectionString);
this.serverConnection.onmessage = function (msg) {
if (this.useWorker) {
this.worker.postMessage(msg.data);
} else {
let json = JSON.parse(msg.data);
let snapshot = this.onServerMessage(json);
// this.log(snapshot)
// run the optional preProcessing function on all messages
// this is currently used to parse gamestate for events to emit
if (snapshot && true) { // Remark: This could be configurable for performance
self.emitGamestateEvents(snapshot);
}
if (this._onServerMessage) {
this._onServerMessage(snapshot);
}
}
}.bind(this);
this.serverConnection.addEventListener('error', (error) => {
console.error('WebSocket error:', error);
});
});
};
/**
* Disconnects the client from the current world if a connection exists.
*
* @function
* @memberof YantraClient
* @instance
* @returns {YantraClient} - The `YantraClient` instance, allowing for method chaining.
* @example
* // Disconnect from the current world
* client.disconnect();
*/
YantraClient.prototype.disconnect = function () {
if (this.serverConnection) {
this.log('disconnecting...');
this.serverConnection.close();
this.connected = false;
}
return this;
};
YantraClient.prototype.joinWorld = function () {
this.sendJSON({ event: 'player_identified' });
};
/**
* Sends a JSON object to the connected server if a connection exists.
*
* @function
* @memberof YantraClient
* @instance
* @param {Object} json - The JSON object to send to the server.
* @returns {YantraClient} - The `YantraClient` instance, allowing for method chaining.
* @example
* // Send a JSON object to the server
* client.sendJSON({ key: 'value' });
*/
YantraClient.prototype.sendJSON = function (json) {
// if creator_json, reroute to sendState instead
if (this.serverConnection) {
if (json.event === 'creator_json') {
this.pushStateCache.push(json);
} else {
this.serverConnection.send(JSON.stringify(json));
}
} else {
this.log('WARNING: not connected to world, cannot send JSON');
}
return this;
}
// another approach is to not send creator_json until gamestate event /
// only send one creator_json per gamestate event
// that would ensure fixed order of creator_json and gamestate events
// similiar to sendJSON, but sends buffered state array
// buffers state for 1 ms, then sends
// this is to prevent sending state too often
YantraClient.prototype.sendState = function (json) {
if (this.serverConnection) {
this.serverConnection.send(JSON.stringify(json));
}
return this;
}
YantraClient.prototype._onOpen = function (wsConnectionString) {
this.connectAttempts = 0;
this.connected = true;
this.log('WebSocket connection opened! ' + wsConnectionString);
// serverSettings.paused = false;
this.emit('open');
this.emit('connect', this);
};
YantraClient.prototype._onError = function (wsConnectionString) {
this.log('WebSocket connection error' + wsConnectionString);
this.emit('error');
};
YantraClient.prototype._onClose = function (wsConnectionString, event) {
this.log('WebSocket connection closed ' + wsConnectionString);
// snapshot.clear();
// serverSettings.paused = true;
this.connected = false;
const { code, reason, wasClean } = event;
if (false && !wasClean) {
let msg = `Connection closed with unclean disconnect, code=${code} reason=${reason}`;
this.log(msg);
console.error(msg);
setTimeout(() => {
let msg = this.connectAttempts + ` attempting to reconnect...`;
this.log(msg);
this.log(msg);
this.connect(wsConnectionString);
}, 3333);
}
this.emit('close');
};
YantraClient.prototype.clearAllState = clearAll;
YantraClient.prototype.events = {};
/**
* Registers an event listener for the specified event.
*
* @function
* @memberof YantraClient
* @instance
* @param {string} event - The name of the event to listen for.
* @param {function} fn - The callback function to execute when the event is emitted.
* @example
* // Register an event listener for the 'update' event.
* client.on('update', function(data) {
* this.log('Update event received:', data);
* });
*/
YantraClient.prototype.on = function (event, fn) {
this.events[event] = this.events[event] || [];
this.events[event].push(fn);
}
/**
* Emits an event, causing all registered listeners for that event to be called.
*
* @function
* @memberof YantraClient
* @instance
* @param {string} event - The name of the event to emit.
* @param {*} [data] - The data to pass to the listeners of the event.
* @example
* // Emit an 'update' event with data.
* client.emit('update', { key: 'value' });
*/
YantraClient.prototype.emit = function (event, data) {
let self = this;
if (this.events[event]) {
this.events[event].forEach(function (fn) {
fn.call(self, data);
});
}
}
YantraClient.prototype.welcomeLink = function welcomeLink(owner, mode, env) {
if (typeof env === 'undefined') {
env = 'dev';
}
let headerStr = '--REMOTE ENVIRONMENT DETECTED--';
let gameLink = `https://yantra.gg/mantra/yantra?mode=${mode}&owner=${owner}`;
if (env !== 'prod') {
gameLink += `&env=${env}`;
} else {
headerStr = 'PRODUCTION ENVIRONMENT DETECTED';
}
this.log('\n');
this.log('¢∞§ -------' + headerStr + '------ §∞¢');
this.log('¢∞§ §∞¢');
this.log('');
this.log(gameLink);
this.log('');
this.log(' This link will open the game in browser')
this.log('¢∞§ §∞¢');
this.log('¢∞§ Enjoy! Have fun! §∞¢');
this.log('¢∞§ §∞¢');
this.log('¢∞§ ---------------- MANTRA ---------------- §∞¢');
this.log('\n\n');
}
export default YantraClient;