├── tests ├── mock │ ├── components │ │ └── test-component.js │ ├── level-provider.js │ └── templates │ │ └── test.json ├── setup.js ├── index.test.js ├── utils │ ├── client.js │ ├── server.js │ └── constants.js ├── lifecycle.js ├── messages.js └── rooms.js ├── .npmignore ├── docs ├── templates │ ├── index-property-item.ejs │ ├── property-item.ejs │ ├── index-event-item.ejs │ ├── index.ejs │ ├── index-function-item.ejs │ ├── event-item.ejs │ ├── callback-item.ejs │ ├── function-item.ejs │ └── class.ejs ├── client │ ├── Levels.md │ ├── README.md │ ├── User.md │ ├── InterpolateValue.md │ ├── NetworkEntity.md │ ├── Room.md │ └── PlayNetwork.md ├── server │ ├── README.md │ ├── Users.md │ ├── Rooms.md │ ├── NetworkEntity.md │ ├── Room.md │ ├── User.md │ └── PlayNetwork.md └── README.md ├── .gitattributes ├── src ├── server │ ├── core │ │ ├── servers.js │ │ ├── network-entities │ │ │ ├── parsers.js │ │ │ ├── network-entities.js │ │ │ └── network-entity.js │ │ ├── server.js │ │ ├── users.js │ │ ├── rooms.js │ │ ├── user.js │ │ └── room.js │ ├── libs │ │ ├── utils.js │ │ ├── levels.js │ │ ├── assets.js │ │ ├── logger.js │ │ ├── performance.js │ │ ├── templates.js │ │ └── scripts.js │ └── index.js └── client │ ├── network-entities │ └── network-entities.js │ ├── user.js │ ├── levels.js │ ├── room.js │ ├── interpolation.js │ └── index.js ├── rollup.config.js ├── LICENSE ├── .eslintrc.json ├── .gitignore ├── package.json ├── jest.config.mjs ├── README.md ├── docs.js └── dist └── network-entity.js /tests/mock/components/test-component.js: -------------------------------------------------------------------------------- 1 | pc.createScript('test'); 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | /docs.js 3 | /rollup.config.js 4 | /package-lock.json 5 | /tmpl 6 | /examples 7 | /node_modules 8 | /tests 9 | -------------------------------------------------------------------------------- /docs/templates/index-property-item.ejs: -------------------------------------------------------------------------------- 1 | .<%=name%> : <%-locals.type.names.map((i) => { 2 | return `${i}`; 3 | }).join(' | ')%> 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/node/libs/ammo.js linguist-vendored 2 | src/server/libs/permessage-deflate/**/* linguist-vendored 3 | package-lock.json linguist-generated 4 | dist/**/* linguist-generated 5 | -------------------------------------------------------------------------------- /docs/templates/property-item.ejs: -------------------------------------------------------------------------------- 1 | 2 | ### .<%=name%> : <%-locals.type.names.map((i) => { 3 | return `${i}`; 4 | }).join(' | ')%> 5 | <%if(description){%><%-description%> 6 | <%}%> 7 | -------------------------------------------------------------------------------- /docs/templates/index-event-item.ejs: -------------------------------------------------------------------------------- 1 | <%=name%><%if(locals?.params?.length){%> => (<%=locals?.params?.filter((i) => { 2 | return i.name.indexOf('.') === -1; 3 | }).map((i) => { 4 | return i.optional ? `[${i.name}]`: i.name; 5 | }).join(', ')%>)<%}%> 6 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import { HTMLCanvasElement } from '@playcanvas/canvas-mock/src/index.mjs'; 2 | 3 | import * as pc from 'playcanvas'; 4 | global.pc = pc; 5 | 6 | const canvas = new HTMLCanvasElement(100, 100); 7 | canvas.id = -1; 8 | const app = new pc.Application(canvas); 9 | app.autoRender = false; 10 | app.start(); 11 | -------------------------------------------------------------------------------- /docs/templates/index.ejs: -------------------------------------------------------------------------------- 1 | # <%=title%> 2 | 3 | <%for(const [className, classItem] of classes) {%> 4 | ### <%=className%> 5 | <%-classItem.description%> 6 | <%}%> 7 | 8 | <% if (links.size) {%><% for(const [className, link] of links) { -%> 9 | [<%=className%>]: <%=link%> 10 | <% } %><% } -%> -------------------------------------------------------------------------------- /tests/mock/level-provider.js: -------------------------------------------------------------------------------- 1 | class MockLevelProvider { 2 | mockData = new Map(); 3 | 4 | save(id, data) { 5 | this.mockData.set(id, data); 6 | } 7 | 8 | load(id) { 9 | return this.mockData.get(id); 10 | } 11 | 12 | has(id) { 13 | return this.mockData.has(id); 14 | } 15 | } 16 | 17 | export default MockLevelProvider; 18 | -------------------------------------------------------------------------------- /docs/templates/index-function-item.ejs: -------------------------------------------------------------------------------- 1 | <%=locals.kind==='constructor'?'new ':''%><%=name%>(<%=locals?.params?.filter((i) => { 2 | return i.name.indexOf('.') === -1; 3 | }).map((i) => { 4 | return i.optional ? `[${i.name}]`: i.name; 5 | }).join(', ')%>)<%=locals.async?' [async]':''%><%if(locals.returns) {%> => <%-returns[0].type.names.map((i) => { 6 | return `${i}`; 7 | }).join(' | ')%><%}%> 8 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import { describe } from '@jest/globals'; 2 | 3 | import { initialization, destruction } from './lifecycle.js'; 4 | import { clientMessages, serverMessages } from './messages.js'; 5 | import { levels, rooms } from './rooms.js'; 6 | 7 | initialization(); 8 | 9 | describe('Main tests', () => { 10 | clientMessages(); 11 | serverMessages(); 12 | levels(); 13 | rooms(); 14 | }); 15 | 16 | destruction(); 17 | -------------------------------------------------------------------------------- /tests/utils/client.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | export const createClient = (callback) => { 4 | jest.isolateModules(async () => { 5 | await import('./../../dist/pn.js'); 6 | callback(window.pn); 7 | }); 8 | }; 9 | 10 | export const connectClient = (client, port, callback) => { 11 | const host = 'localhost'; 12 | const useSSL = false; 13 | const payload = null; 14 | client.connect(host, port, useSSL, payload, callback); 15 | }; 16 | -------------------------------------------------------------------------------- /src/server/core/servers.js: -------------------------------------------------------------------------------- 1 | import pn from './../index.js'; 2 | import Server from './server.js'; 3 | 4 | export default class Servers extends Map { 5 | async get(id, callback) { 6 | let server = super.get(id); 7 | if (!server) { 8 | const url = await pn.redis.HGET('_route:server', id.toString()); 9 | if (!url) return null; 10 | 11 | server = new Server(id, url); 12 | this.set(id, server); 13 | } 14 | 15 | callback(server); 16 | return server; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/client/network-entities/network-entities.js: -------------------------------------------------------------------------------- 1 | class NetworkEntities { 2 | constructor() { 3 | this._index = new Map(); 4 | } 5 | 6 | get(id) { 7 | return this._index.get(id); 8 | } 9 | 10 | has(id) { 11 | return this._index.has(id); 12 | } 13 | 14 | add(networkEntity) { 15 | if (this.has(networkEntity.id)) return; 16 | 17 | this._index.set(networkEntity.id, networkEntity); 18 | 19 | networkEntity.entity.once('destroy', () => { 20 | this._index.delete(networkEntity.id) 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/utils/server.js: -------------------------------------------------------------------------------- 1 | import pn from './../../src/server/index.js'; 2 | import { createServer as createHttpServer } from 'http'; 3 | import MockLevelProvider from '../mock/level-provider.js'; 4 | 5 | export const createServer = async (port) => { 6 | const server = createHttpServer(); 7 | server.listen(port); 8 | 9 | await pn.start({ 10 | scriptsPath: './tests/mock/components', 11 | templatesPath: './tests/mock/templates', 12 | redisUrl: 'redis://localhost:6379', 13 | server, 14 | useAmmo: false, 15 | levelProvider: new MockLevelProvider() 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /docs/client/Levels.md: -------------------------------------------------------------------------------- 1 | # Levels (client) 2 | 3 | Interface that allows to save hierarchy data to a server. 4 | 5 | --- 6 | 7 | # Index 8 | 9 | 10 | ### Functions 11 | 12 | save(sceneId, [callback]) 13 | 14 | 15 | --- 16 | 17 | 18 | # Functions 19 | 20 | 21 | ### save(sceneId, [callback]) 22 | 23 | Save the hierarchy data of a Scene to the server. 24 | 25 | | Param | Type | Description | 26 | | --- | --- | --- | 27 | | sceneId | `Number` | ID of a Scene. | 28 | | callback (optional) | `errorCallback` | Callback of a server response. | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/server/core/network-entities/parsers.js: -------------------------------------------------------------------------------- 1 | import { roundTo } from '../../libs/utils.js'; 2 | 3 | export default new Map([ 4 | [pc.Vec2, (data) => ({ x: roundTo(data.x), y: roundTo(data.y) })], 5 | [pc.Vec3, (data) => ({ x: roundTo(data.x), y: roundTo(data.y), z: roundTo(data.z) })], 6 | [pc.Vec4, (data) => ({ x: roundTo(data.x), y: roundTo(data.y), z: roundTo(data.z), w: roundTo(data.w) })], 7 | [pc.Quat, (data) => ({ x: roundTo(data.x), y: roundTo(data.y), z: roundTo(data.z), w: roundTo(data.w) })], 8 | [pc.Color, (data) => ({ r: data.r, g: data.g, b: data.b, a: data.a })], 9 | [Map, (data) => Array.from(data)], 10 | [Object, (data) => data] 11 | ]); 12 | -------------------------------------------------------------------------------- /docs/templates/event-item.ejs: -------------------------------------------------------------------------------- 1 | 2 | ### <%=name%> [event]<%if(locals?.params?.length){%> => (<%=locals?.params?.filter((i) => { 3 | return i.name.indexOf('.') === -1; 4 | }).map((i) => { 5 | return i.optional ? `[${i.name}]`: i.name; 6 | }).join(', ')%>)<%}%> 7 | <%if(description){%><%-description%> 8 | <%}%><%paramsDescription = params.filter((p)=>{return !!p.description;}).length > 0; 9 | %> 10 | <%if(params.length){-%>| Param | Type |<%if(paramsDescription){%> Description |<%}%> 11 | | --- | --- |<%if(paramsDescription){%> --- |<%}%> 12 | <%for(const item of params){-%> 13 | | <%=item.name%> | <%-item.type.names.map((i) => { 14 | return `${i}`; 15 | }).join(' | ')%> |<%if(paramsDescription){%> <%-item.description%> |<%}%> 16 | <%}%><%}%> 17 | 18 | -------------------------------------------------------------------------------- /docs/templates/callback-item.ejs: -------------------------------------------------------------------------------- 1 | 2 | ### <%=name%> [callback]<%if(locals?.params?.length){%> => (<%=locals?.params?.filter((i) => { 3 | return i.name.indexOf('.') === -1; 4 | }).map((i) => { 5 | return i.optional ? `[${i.name}]`: i.name; 6 | }).join(', ')%>)<%}%> 7 | <%if(description){%><%-description%> 8 | <%}%><%paramsDescription = params.filter((p)=>{return !!p.description;}).length > 0; 9 | %> 10 | <%if(params.length){-%>| Param | Type |<%if(paramsDescription){%> Description |<%}%> 11 | | --- | --- |<%if(paramsDescription){%> --- |<%}%> 12 | <%for(const item of params){-%> 13 | | <%=item.name%><%=item.optional?' (optional)':''%> | <%-item.type.names.map((i) => { 14 | return `${i}`; 15 | }).join(' | ')%> |<%if(paramsDescription){%> <%-item.description%> |<%}%> 16 | <%}%><%}%> 17 | 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import replace from '@rollup/plugin-replace'; 3 | 4 | const config = [{ 5 | input: 'src/client/index.js', 6 | output: { 7 | file: 'dist/pn.js', 8 | format: 'esm' 9 | }, 10 | treeshake: false, 11 | plugins: [ 12 | babel({ 13 | babelHelpers: 'bundled', 14 | babelrc: false, 15 | comments: false, 16 | compact: false, 17 | minified: false 18 | }), 19 | replace({ 20 | preventAssignment: true, 21 | $1: '' 22 | }) 23 | ] 24 | }, { 25 | input: 'src/client/network-entities/network-entity.js', 26 | output: { 27 | dir: 'dist', 28 | format: 'esm' 29 | }, 30 | treeshake: false, 31 | plugins: [ 32 | babel({ babelHelpers: 'bundled' }) 33 | ] 34 | }]; 35 | 36 | export default config; 37 | -------------------------------------------------------------------------------- /docs/templates/function-item.ejs: -------------------------------------------------------------------------------- 1 | 2 | ### <%=locals.kind==='constructor'?'new ':''%><%=name%>(<%=locals?.params?.filter((i) => { 3 | return i.name.indexOf('.') === -1; 4 | }).map((i) => { 5 | return i.optional ? `[${i.name}]`: i.name; 6 | }).join(', ')%>)<%=locals.async?' [async]':''%> 7 | <%if(locals.returns){-%> 8 | **Returns:** <%-returns[0].type.names.map((i) => { 9 | return `${i}`; 10 | }).join(' | ')%> <%}%> 11 | <%if(description){%><%-description%> 12 | <%}%> 13 | <%paramsDescription = params.filter((p)=>{return !!p.description;}).length > 0; 14 | %><%if(params.length){-%>| Param | Type |<%if(paramsDescription){%> Description |<%}%> 15 | | --- | --- |<%if(paramsDescription){%> --- |<%}%> 16 | <%for(const item of params){-%> 17 | | <%-item.name%><%=item.optional?' (optional)':''%> | <%-item.type.names.map((i) => { 18 | return `${i}`; 19 | }).join(' | ')%> |<%if(paramsDescription){%> <%-item.description%> |<%}%> 20 | <%}%><%}%> 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 meta.space 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/server/core/server.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | import WebSocket from 'faye-websocket'; 3 | 4 | export default class Server extends pc.EventHandler { 5 | constructor(id, url) { 6 | super(); 7 | 8 | this.id = id; 9 | this.url = url; 10 | 11 | this._socket = new WebSocket.Client(`ws://${this.url}`, [], { 12 | headers: { 'User-Agent': 'PlayNetwork' } 13 | }); 14 | 15 | this._msgId = 1; 16 | this._callbacks = new Map(); 17 | 18 | this.on('_send', (user, data) => { 19 | user._send(data.name, data.data, data.scope.type, data.scope.id); 20 | }); 21 | } 22 | 23 | send(name, data, scope, id, userId, callback) { 24 | const msg = { 25 | name, 26 | data, 27 | scope: { 28 | type: scope, 29 | id 30 | }, 31 | userId 32 | }; 33 | 34 | if (callback) { 35 | msg.id = this._msgId++; 36 | this._callbacks.set(msg.id, callback); 37 | } 38 | 39 | this._socket.send(JSON.stringify(msg)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/server/libs/utils.js: -------------------------------------------------------------------------------- 1 | import sysPath from 'path'; 2 | import { BACK_SLASH_RE, DOUBLE_SLASH_RE, SLASH, SLASH_SLASH } from 'chokidar/lib/constants.js'; 3 | 4 | const DEFAULT_ROUND_PRECISION = 3; 5 | 6 | export function roundTo(number, digits = DEFAULT_ROUND_PRECISION) { 7 | const pow = Math.pow(10, digits); 8 | return Math.round(number * pow) / pow; 9 | } 10 | 11 | export function guid() { 12 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 13 | const r = Math.random() * 16 | 0; 14 | const v = (c === 'x') ? r : (r & 0x3 | 0x8); 15 | return v.toString(16); 16 | }); 17 | } 18 | 19 | export const unifyPath = (path) => toUnix(sysPath.normalize(toUnix(path))); 20 | 21 | // chokidar path unification 22 | const toUnix = (string) => { 23 | let str = string.replace(BACK_SLASH_RE, SLASH); 24 | let prepend = false; 25 | if (str.startsWith(SLASH_SLASH)) { 26 | prepend = true; 27 | } 28 | while (str.match(DOUBLE_SLASH_RE)) { 29 | str = str.replace(DOUBLE_SLASH_RE, SLASH); 30 | } 31 | if (prepend) { 32 | str = SLASH + str; 33 | } 34 | return str; 35 | }; 36 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser":"@babel/eslint-parser", 3 | "plugins": ["jest"], 4 | "env": { 5 | "node": true, 6 | "es2021": true, 7 | "jest/globals": true 8 | }, 9 | "extends": [ 10 | "standard" 11 | ], 12 | "globals": { 13 | "pc": "readonly", 14 | "DEBUG": "readonly" 15 | }, 16 | "parserOptions": { 17 | "requireConfigFile": false, 18 | "ecmaVersion": "latest", 19 | "sourceType": "module" 20 | }, 21 | "ignorePatterns": ["scripts.js", "ammo.js", "src/client/**/*.js", "dist/**/*.js", "examples/**/components/**/*.js"], 22 | "rules": { 23 | "no-unused-vars": ["error", { "varsIgnorePattern": "_" }], 24 | "semi": ["error", "always"], 25 | "quotes": ["error", "single"], 26 | "indent": ["error", 4, { "SwitchCase": 1 }], 27 | "space-before-function-paren": ["error", { "anonymous": "never", "named": "never", "asyncArrow": "always" }], 28 | "curly": ["off", "all"] 29 | }, 30 | "overrides": [ 31 | { 32 | "files": ["src/components/**/*"], 33 | "rules": { 34 | "no-var": "off" 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/server/libs/levels.js: -------------------------------------------------------------------------------- 1 | import pn from './../index.js'; 2 | 3 | class Levels { 4 | cache = new Map(); 5 | provider = null; 6 | 7 | async initialize(provider) { 8 | this.provider = provider; 9 | 10 | pn.on('_level:save', async (sender, data, callback) => { 11 | await this.save(data.scene, data); 12 | callback(); 13 | }); 14 | } 15 | 16 | async save(id, level) { 17 | if (!Number.isInteger(id) || isNaN(id) || !isFinite(id)) throw new Error('level id should be an integer'); 18 | if (typeof (level) !== 'object') throw new Error('level should be an object'); 19 | 20 | const data = JSON.stringify(level, null, 4); 21 | this.cache.set(id, data); 22 | await this.provider.save(id, data); 23 | 24 | console.log(`level ${id}, saved`); 25 | } 26 | 27 | async load(id) { 28 | if (this.cache.has(id)) return JSON.parse(this.cache.get(id)); 29 | 30 | const data = await this.provider.load(id); 31 | const level = data.toString(); 32 | this.cache.set(id, level); 33 | return JSON.parse(level); 34 | } 35 | 36 | async has(id) { 37 | if (this.cache.has(id)) return true; 38 | return await this.provider.has(id); 39 | } 40 | } 41 | 42 | export default new Levels(); 43 | -------------------------------------------------------------------------------- /src/client/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class User 3 | * @classdesc User object that is created for each {@link User} we know, 4 | * including ourself. 5 | * @extends pc.EventHandler 6 | * @property {number} id Numerical ID of a {@link User}. 7 | * @property {boolean} mine True if {@link User} object is our own. 8 | */ 9 | 10 | /** 11 | * @event User#destroy 12 | * @description Fired when {@link User} has been destroyed 13 | * (not known to client anymore). 14 | */ 15 | 16 | /** 17 | * @event User#* 18 | * @description Fired when a {@link User} received named network message. 19 | * @param {object|array|string|number|boolean} [data] Message data. 20 | */ 21 | 22 | class User extends pc.EventHandler { 23 | constructor(id, mine) { 24 | super(); 25 | 26 | this.id = id; 27 | this.mine = mine; 28 | } 29 | 30 | /** 31 | * @method send 32 | * @description Send named message to a server User. 33 | * @param {string} name Name of a message. 34 | * @param {null|object|array|string|number|boolean} [data] JSON friendly message data. 35 | * @param {responseCallback} [callback] Callback that will be fired when response message is received. 36 | */ 37 | send(name, data, callback) { 38 | pn._send(name, data, 'user', this.id, callback); 39 | } 40 | 41 | destroy() { 42 | this.fire('destroy'); 43 | this.off(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/server/README.md: -------------------------------------------------------------------------------- 1 | # Server 2 | 3 | 4 | ### PlayNetwork 5 | Main interface of PlayNetwork server. This class handles clients connection and communication. 6 | 7 | ### NetworkEntity 8 | NetworkEntity is a [pc.ScriptType], which is attached to a [pc.ScriptComponent] of an [pc.Entity] that needs to be synchronised between server and clients. It has unique ID, optional owner and list of properties to be synchronised. For convenience, [pc.Entity] has additional property: `entity.networkEntity`. 9 | 10 | ### Room 11 | A Room represents own [pc.Application] context, with a list of joined [User]s. 12 | 13 | ### Rooms 14 | Interface with a list of server [Room]s and an interface to create new rooms. 15 | 16 | ### User 17 | User interface which is created for each individual connection and inter-connections to a [PlayNetwork]. 18 | 19 | ### Users 20 | Interface of all [User]s currently connected to a server. As well as for handling new user authentication. 21 | 22 | 23 | [pc.ScriptType]: https://developer.playcanvas.com/en/api/pc.ScriptType.html 24 | [pc.ScriptComponent]: https://developer.playcanvas.com/en/api/pc.ScriptComponent.html 25 | [pc.Entity]: https://developer.playcanvas.com/en/api/pc.Entity.html 26 | [pc.Application]: https://developer.playcanvas.com/en/api/pc.Application.html 27 | [User]: ./User.md 28 | [Room]: ./Room.md 29 | [PlayNetwork]: ./PlayNetwork.md 30 | -------------------------------------------------------------------------------- /docs/templates/class.ejs: -------------------------------------------------------------------------------- 1 | # <%-name%> (<%=scope%>)<%if(locals.extends){%> 2 | extends <%-locals.extends-%> 3 | <%}%> 4 | 5 | <%-description%> 6 | 7 | --- 8 | 9 | # Index 10 | <% if (properties.length) { %> 11 | ### Properties 12 | 13 | <% for(const item of properties) { %><%- include('index-property-item', item) %><% } %><% } -%> 14 | 15 | <% if (locals.events && events.size) {%>### Events 16 | 17 | <% for(const item of events) { %><%- include('index-event-item', item); %><% } %><% } -%> 18 | <% if (constructor) {%>### Constructor 19 | <%- include('index-function-item', constructor) -%><%}-%> 20 | 21 | <% if (functions.size) {%>### Functions 22 | 23 | <% for(const item of functions) { %><%- include('index-function-item', item) %><% } %><% } %> 24 | 25 | --- 26 | 27 | <% if (properties.length) { %> 28 | # Properties 29 | 30 | <% for(const item of properties) { %><%- include('property-item', item) %><% } %><% } %> 31 | <% if (events.size) {%> 32 | # Events 33 | 34 | <% for(const item of events) { %><%- include('event-item', item) %><% } %><% } -%> 35 | <% if (constructor) {-%> 36 | # Constructor 37 | 38 | <%- include('function-item', constructor); %> 39 | <% } -%><% if (functions.size) {-%> 40 | # Functions 41 | 42 | <% for(const item of functions) { %><%- include('function-item', item) %><% } %><% } -%> 43 | 44 | <% if(callbacks.size) { %># Callbacks 45 | <% for(const item of callbacks) { %> 46 | <%- include('callback-item', item); %> 47 | <% } -%><% } -%> 48 | 49 | <% if (links.size) {%><% for(const [className, link] of links) { -%> 50 | [<%=className%>]: <%=link%> 51 | <% } %><% } -%> -------------------------------------------------------------------------------- /docs/client/README.md: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | 4 | ### PlayNetwork 5 | Main interface to connect to a server and interact with networked data. 6 | 7 | ### InterpolateValue 8 | Helper class to interpolate values between states. It has mechanics to smoothen unreliable intervals of state and can interpolate simple values such as `number`, as well as complex: [pc.Vec2], [pc.Vec3], [pc.Vec4], [pc.Quat], [pc.Color]. 9 | 10 | ### Levels 11 | Interface that allows to save hierarchy data to a server. 12 | 13 | ### NetworkEntity 14 | NetworkEntity is a [pc.ScriptType], which is attached to a [pc.ScriptComponent] of an [pc.Entity] that needs to be synchronised between server and clients. It has unique ID, optional owner and list of properties to be synchronised. For convenience, [pc.Entity] has additional property: `entity.networkEntity`. 15 | 16 | ### Room 17 | Room to which [User] has joined. 18 | 19 | ### User 20 | User object that is created for each [User] we know, including ourself. 21 | 22 | 23 | [pc.Vec2]: https://developer.playcanvas.com/en/api/pc.Vec2.html 24 | [pc.Vec3]: https://developer.playcanvas.com/en/api/pc.Vec3.html 25 | [pc.Vec4]: https://developer.playcanvas.com/en/api/pc.Vec4.html 26 | [pc.Quat]: https://developer.playcanvas.com/en/api/pc.Quat.html 27 | [pc.Color]: https://developer.playcanvas.com/en/api/pc.Color.html 28 | [pc.ScriptType]: https://developer.playcanvas.com/en/api/pc.ScriptType.html 29 | [pc.ScriptComponent]: https://developer.playcanvas.com/en/api/pc.ScriptComponent.html 30 | [pc.Entity]: https://developer.playcanvas.com/en/api/pc.Entity.html 31 | [User]: ./User.md 32 | -------------------------------------------------------------------------------- /src/server/libs/assets.js: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import { unifyPath } from './utils.js'; 5 | 6 | async function _loadAssetById(id, token) { 7 | const options = { 8 | hostname: 'playcanvas.com', 9 | path: `/api/assets/${id}`, 10 | headers: { 11 | Authorization: `Bearer ${token}` 12 | } 13 | }; 14 | 15 | return new Promise((resolve) => { 16 | https.get(options, (response) => { 17 | if (response.statusCode !== 200) { 18 | resolve(null); 19 | return; 20 | } 21 | 22 | let result = ''; 23 | 24 | response.on('data', function(chunk) { 25 | result += chunk; 26 | }); 27 | 28 | response.on('end', function() { 29 | resolve(result); 30 | }); 31 | }); 32 | }); 33 | }; 34 | 35 | export async function downloadAsset(saveTo, id, token) { 36 | if (!token) { 37 | console.error('No playcanvas token provided'); 38 | return false; 39 | } 40 | 41 | const asset = await _loadAssetById(id, token); 42 | if (!asset) return; 43 | 44 | await fs.mkdir(path.dirname(saveTo), { recursive: true }); 45 | await fs.writeFile(saveTo, asset); 46 | 47 | return true; 48 | }; 49 | 50 | export async function updateAssets(directory, token) { 51 | if (!token) { 52 | console.error('No playcanvas token provided'); 53 | return false; 54 | } 55 | 56 | directory = unifyPath(directory); 57 | 58 | const files = await fs.readdir(directory); 59 | 60 | for (let i = 0; i < files.length; i++) { 61 | const _path = `${directory}${path.sep}${files[i]}`; 62 | const stats = await fs.stat(_path); 63 | 64 | if (stats.isDirectory()) { 65 | await updateAssets(_path, token); 66 | } else if (stats.isFile()) { 67 | const asset = JSON.parse(await fs.readFile(_path)); 68 | await downloadAsset(_path, asset.id, token); 69 | } 70 | } 71 | 72 | return true; 73 | }; 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | 84 | # Gatsby files 85 | .cache/ 86 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # public 89 | 90 | # vuepress build output 91 | .vuepress/dist 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | 105 | *.pem 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playnetwork", 3 | "version": "0.6.1", 4 | "description": "", 5 | "exports": { 6 | ".": "./src/server/index.js", 7 | "./node": "./src/node/index.js" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "build": "npx rollup -c", 12 | "docs:server": "node docs.js Server ./docs/server/ ./src/server/index.js ./src/server/core/**/*.js", 13 | "docs:client": "node docs.js Client ./docs/client/ ./src/client/**/*.js", 14 | "docs": "npm run docs:server & npm run docs:client", 15 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit --runInBand" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/meta-space-org/playnetwork.git" 20 | }, 21 | "author": "meta.space", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/meta-space-org/playcanvas-server-boilerplate/issues" 25 | }, 26 | "homepage": "https://github.com/meta-space-org/playcanvas-server-boilerplate#readme", 27 | "dependencies": { 28 | "@playcanvas/canvas-mock": "^1.0.1", 29 | "chokidar": "^3.5.3", 30 | "fast-deep-equal": "^3.1.3", 31 | "faye-websocket": "^0.11.4", 32 | "permessage-deflate": "^0.1.7", 33 | "playcanvas": "^1.61.3", 34 | "redis": "^4.6.5" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.21.0", 38 | "@babel/core": "^7.21.3", 39 | "@babel/eslint-parser": "^7.21.3", 40 | "@babel/plugin-proposal-class-properties": "^7.18.6", 41 | "@babel/preset-env": "^7.20.2", 42 | "@playcanvas/jsdoc-template": "^1.0.29", 43 | "@rollup/plugin-babel": "^6.0.3", 44 | "@rollup/plugin-replace": "^5.0.2", 45 | "babel-plugin-remove-import-export": "^1.1.1", 46 | "ejs": "^3.1.9", 47 | "eslint": "^8.37.0", 48 | "eslint-config-standard": "^17.0.0", 49 | "eslint-plugin-import": "^2.27.5", 50 | "eslint-plugin-jest": "^27.2.1", 51 | "eslint-plugin-node": "^11.1.0", 52 | "eslint-plugin-promise": "^6.1.1", 53 | "jest": "^29.5.0", 54 | "jest-environment-jsdom": "^29.5.0", 55 | "jsdoc-api": "^8.0.0", 56 | "jsdoc-parse": "^6.2.0" 57 | }, 58 | "optionalDependencies": { 59 | "bufferutil": "^4.0.7" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/lifecycle.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | 3 | import pn from '../src/server/index.js'; 4 | 5 | import { createServer } from './utils/server.js'; 6 | import { createClient, connectClient } from './utils/client.js'; 7 | 8 | export const initialization = () => { 9 | describe('Initialization', () => { 10 | test('Server create failed', async () => { 11 | await expect(pn.start()).rejects.toThrow(); 12 | }); 13 | 14 | test('Server created', async () => { 15 | await createServer(8080); 16 | expect(pn.id).toEqual(expect.anything()); 17 | }); 18 | 19 | test('Client initialized', (done) => { 20 | createClient((client) => { 21 | try { 22 | expect(client).toBeDefined(); 23 | expect(client.levels).toBeDefined(); 24 | global.client = client; 25 | } finally { 26 | done(); 27 | } 28 | }); 29 | }); 30 | 31 | test('Client connected', (done) => { 32 | connectClient(global.client, 8080, (err, user) => { 33 | try { 34 | expect(err).toBeNull(); 35 | expect(user).toEqual(expect.anything()); 36 | expect(pn.users._index.has(user.id)).toBeTruthy(); 37 | expect(global.client.me).toEqual(expect.anything()); 38 | expect(global.client.me.mine).toBeTruthy(); 39 | global.serverUser = pn.users._index.get(user.id); 40 | } finally { 41 | done(); 42 | } 43 | }); 44 | }); 45 | }); 46 | }; 47 | 48 | export const destruction = () => { 49 | describe('Destruction', () => { 50 | test('Client disconnect', (done) => { 51 | pn.users.once('disconnect', (user) => { 52 | try { 53 | expect(user.id).toBe(global.serverUser.id); 54 | expect(pn.users._index.has(user.id)).toBeFalsy(); 55 | } finally { 56 | done(); 57 | } 58 | }); 59 | 60 | global.client.socket.close(); 61 | }); 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/server/core/users.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | import pn from './../index.js'; 3 | import User from './user.js'; 4 | 5 | /** 6 | * @class Users 7 | * @classdesc Interface of all {@link User}s currently connected to a server. As well as for handling new user authentication. 8 | * @extends pc.EventHandler 9 | */ 10 | 11 | /** 12 | * @callback authenticateCallback 13 | * @param {Error} [error] {@link Error} object if authentication failed. 14 | * @param {number|string} userId User ID if authentication succeeded. 15 | */ 16 | 17 | /** 18 | * @event Users#connect 19 | * @description Fired when new user has been connected. 20 | * @param {User} user 21 | */ 22 | 23 | /** 24 | * @event Users#disconnect 25 | * @description Fired when a user has been disconnected. 26 | * @param {User} user 27 | */ 28 | 29 | /** 30 | * @event Users#authenticate 31 | * @description Event to handle new connected sockets and authenticate a user. Callback should be called with an error or userId provided. 32 | * @param {object|array|string|number|boolean} payload Payload data sent from a client. 33 | * @param {authenticateCallback} callback Callback that should be called when authentication is finished. By providing userId - authentication considered successfull. 34 | */ 35 | 36 | export default class Users extends pc.EventHandler { 37 | _index = new Map(); 38 | 39 | add(user) { 40 | this._index.set(user.id, user); 41 | 42 | user.once('destroy', () => { 43 | this._index.delete(user.id); 44 | this.fire('disconnect', user); 45 | }); 46 | 47 | this.fire('connect', user); 48 | } 49 | 50 | /** 51 | * @method get 52 | * @description Get {@link User} by ID 53 | * @param {number} id 54 | * @returns {User|null} 55 | */ 56 | async get(id) { 57 | if (!this._index.has(id)) { 58 | const serverId = parseInt(await pn.redis.HGET('_route:user', id.toString())); 59 | if (!serverId) return null; 60 | 61 | const user = new User(id, null, serverId); 62 | this._index.set(user.id, user); 63 | 64 | user.once('destroy', () => { 65 | this._index.delete(user.id); 66 | }); 67 | } 68 | 69 | return this._index.get(id); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/server/libs/logger.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | let relativePath = ''; 4 | 5 | global.console.setRelativePath = (filePath) => { 6 | relativePath = path.dirname(filePath); 7 | }; 8 | 9 | function processStack(stack) { 10 | let lines = stack.split('\n'); 11 | 12 | lines = lines.filter((line, ind) => { 13 | if (ind === 0) { 14 | // remove first error message 15 | return false; 16 | } else if (line.includes('node:internal')) { 17 | // remove internal errors 18 | return false; 19 | } 20 | return true; 21 | }); 22 | 23 | lines = lines.map((line) => { 24 | line = line.slice(7); // remove "at" 25 | if (relativePath && line.includes(relativePath)) { 26 | // make paths relative to project 27 | line = line.replace(relativePath, ''); 28 | } if (line.includes('node_modules\\playcanvas\\build\\playcanvas.js:')) { 29 | // replace playcanvas engine path 30 | line = line.replace(/\s\([a-zA-Z:\\_]+playcanvas\.js:/, ' (playcanvas.js:'); 31 | } 32 | 33 | // :line:char > :line 34 | line = line.replace(/(:[0-9]+):[0-9]+/, '$1'); 35 | 36 | // name (location) > location (name) 37 | line = line.replace(/([a-zA-Z0-9\\._<>]+)\s\(([a-zA-Z\\./]+:[0-9]+)\)/, '$2 ($1)'); 38 | 39 | return line; 40 | }); 41 | 42 | return lines.join('\n'); 43 | }; 44 | 45 | const consoleLog = console.log; 46 | const consoleInfo = console.info; 47 | const consoleWarn = console.warn; 48 | const consoleError = console.error; 49 | 50 | global.console.log = function(...args) { 51 | consoleLog.call(console, ...args); 52 | }; 53 | 54 | global.console.info = function(...args) { 55 | consoleInfo.call(console, '\x1b[36m%s', ...args, '\x1b[0m'); 56 | }; 57 | 58 | global.console.warn = function(...args) { 59 | consoleWarn.call(console, '\x1b[33m%s', ...args, '\x1b[0m'); 60 | }; 61 | 62 | global.console.error = function(...args) { 63 | if (args[0] instanceof Error) { 64 | const err = args[0]; 65 | consoleError.call(console, '\x1b[31m%s\x1b[0m', `ERROR: ${err.message}`); 66 | consoleError.call(console, processStack(err.stack)); 67 | } else { 68 | consoleError.call(console, ...args); 69 | } 70 | }; 71 | 72 | export default console; 73 | -------------------------------------------------------------------------------- /docs/client/User.md: -------------------------------------------------------------------------------- 1 | # User (client) 2 | extends [pc.EventHandler] 3 | 4 | User object that is created for each [User] we know, including ourself. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `number` 13 | .mine : `boolean` 14 | 15 | ### Events 16 | 17 | destroy 18 | * => ([data]) 19 | 20 | ### Functions 21 | 22 | send(name, [data], [callback]) 23 | 24 | 25 | --- 26 | 27 | 28 | # Properties 29 | 30 | 31 | ### .id : `number` 32 | Numerical ID of a [User]. 33 | 34 | 35 | ### .mine : `boolean` 36 | True if [User] object is our own. 37 | 38 | 39 | 40 | # Events 41 | 42 | 43 | ### destroy [event] 44 | Fired when [User] has been destroyed (not known to client anymore). 45 | 46 | 47 | 48 | 49 | ### * [event] => ([data]) 50 | Fired when a [User] received named network message. 51 | 52 | | Param | Type | Description | 53 | | --- | --- | --- | 54 | | data | `object` | `array` | `string` | `number` | `boolean` | Message data. | 55 | 56 | 57 | # Functions 58 | 59 | 60 | ### send(name, [data], [callback]) 61 | 62 | Send named message to a server User. 63 | 64 | | Param | Type | Description | 65 | | --- | --- | --- | 66 | | name | `string` | Name of a message. | 67 | | data (optional) | `null` | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 68 | | callback (optional) | responseCallback | Callback that will be fired when response message is received. | 69 | 70 | 71 | 72 | # Callbacks 73 | 74 | 75 | ### responseCallback [callback] => (error, data) 76 | 77 | | Param | Type | Description | 78 | | --- | --- | --- | 79 | | error | ````null```` | ```[Error]``` | Error provided with with a response. | 80 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 81 | 82 | 83 | 84 | 85 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 86 | [User]: ./User.md 87 | -------------------------------------------------------------------------------- /src/server/core/network-entities/network-entities.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | 3 | import pn from './../../index.js'; 4 | import entityToData from './entity-parser.js'; 5 | 6 | export default class NetworkEntities extends pc.EventHandler { 7 | index = new Map(); 8 | entitiesInProcess = 0; 9 | nextEntityId = 0; 10 | 11 | constructor(app) { 12 | super(); 13 | 14 | this.app = app; 15 | } 16 | 17 | create(script) { 18 | script.entity.forEach((e) => { 19 | if (!e.networkEntity) return; 20 | 21 | const id = `${pn.id}-${this.nextEntityId}`; 22 | this.nextEntityId++; 23 | 24 | e.networkEntity.id = id; 25 | pn.redis.HSET('_route:networkEntity', id, pn.id); 26 | 27 | this.index.set(id, e); 28 | pn.networkEntities.set(id, e.networkEntity); 29 | 30 | e.networkEntity.once('destroy', () => { 31 | if (!this.index.has(id)) return; 32 | 33 | e.forEach((x) => { 34 | if (!x.networkEntity) return; 35 | this.index.delete(id); 36 | pn.networkEntities.delete(id); 37 | pn.redis.HDEL('_route:networkEntity', id.toString()); 38 | }); 39 | 40 | this.app.room.send('_networkEntities:delete', id); 41 | }); 42 | }); 43 | 44 | if (!this.app.frame) return; 45 | 46 | this.app.room.send('_networkEntities:create', { entities: this.toData(script.entity) }); 47 | } 48 | 49 | delete(id) { 50 | this.index.delete(id); 51 | pn.networkEntities.delete(id); 52 | } 53 | 54 | get(id) { 55 | return this.index.get(id) || null; 56 | } 57 | 58 | getState(force) { 59 | const state = []; 60 | for (const entity of this.index.values()) { 61 | if (!entity.script || !entity.script.networkEntity) 62 | continue; 63 | 64 | const entityState = entity.script.networkEntity.getState(force); 65 | 66 | if (entityState) 67 | state.push(entityState); 68 | } 69 | return state; 70 | } 71 | 72 | toData(entity) { 73 | const entities = {}; 74 | 75 | entity.forEach((e) => { 76 | if (!(e instanceof pc.Entity)) 77 | return; 78 | 79 | const entityData = entityToData(e); 80 | entities[entityData.resource_id] = entityData; 81 | }); 82 | 83 | return entities; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docs/server/Users.md: -------------------------------------------------------------------------------- 1 | # Users (server) 2 | extends [pc.EventHandler] 3 | 4 | Interface of all [User]s currently connected to a server. As well as for handling new user authentication. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Events 11 | 12 | connect => (user) 13 | disconnect => (user) 14 | authenticate => (payload, callback) 15 | 16 | ### Functions 17 | 18 | get(id) => [User] | `null` 19 | 20 | 21 | --- 22 | 23 | 24 | 25 | # Events 26 | 27 | 28 | ### connect [event] => (user) 29 | Fired when new user has been connected. 30 | 31 | | Param | Type | 32 | | --- | --- | 33 | | user | [User] | 34 | 35 | 36 | 37 | ### disconnect [event] => (user) 38 | Fired when a user has been disconnected. 39 | 40 | | Param | Type | 41 | | --- | --- | 42 | | user | [User] | 43 | 44 | 45 | 46 | ### authenticate [event] => (payload, callback) 47 | Event to handle new connected sockets and authenticate a user. Callback should be called with an error or userId provided. 48 | 49 | | Param | Type | Description | 50 | | --- | --- | --- | 51 | | payload | `object` | `array` | `string` | `number` | `boolean` | Payload data sent from a client. | 52 | | callback | authenticateCallback | Callback that should be called when authentication is finished. By providing userId - authentication considered successfull. | 53 | 54 | 55 | # Functions 56 | 57 | 58 | ### get(id) 59 | 60 | **Returns:** [User] | `null` 61 | Get [User] by ID 62 | 63 | | Param | Type | 64 | | --- | --- | 65 | | id | `number` | 66 | 67 | 68 | 69 | # Callbacks 70 | 71 | 72 | ### authenticateCallback [callback] => ([error], userId) 73 | 74 | | Param | Type | Description | 75 | | --- | --- | --- | 76 | | error (optional) | [Error] | [Error] object if authentication failed. | 77 | | userId | `number` | `string` | User ID if authentication succeeded. | 78 | 79 | 80 | 81 | 82 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 83 | [User]: ./User.md 84 | [Error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error 85 | -------------------------------------------------------------------------------- /docs/server/Rooms.md: -------------------------------------------------------------------------------- 1 | # Rooms (server) 2 | extends [pc.EventHandler] 3 | 4 | Interface with a list of server [Room]s and an interface to create new rooms. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Events 11 | 12 | join => (room, user) 13 | leave => (room, user) 14 | 15 | ### Functions 16 | 17 | create(levelId, [tickrate]) [async] => [Room] 18 | get(id) => [Room] | `null` 19 | has(id) => `boolean` 20 | 21 | 22 | --- 23 | 24 | 25 | 26 | # Events 27 | 28 | 29 | ### join [event] => (room, user) 30 | Fired when a [User] successfully joined a [Room]. 31 | 32 | | Param | Type | Description | 33 | | --- | --- | --- | 34 | | room | [Room] | [Room] to which a [User] joined. | 35 | | user | [User] | [User] who joined. | 36 | 37 | 38 | 39 | ### leave [event] => (room, user) 40 | Fired when a [User] left a [Room]. 41 | 42 | | Param | Type | Description | 43 | | --- | --- | --- | 44 | | room | [Room] | [Room] from which a [User] has left. | 45 | | user | [User] | [User] who has left. | 46 | 47 | 48 | # Functions 49 | 50 | 51 | ### create(levelId, [tickrate]) [async] 52 | 53 | **Returns:** [Room] 54 | Function to create a new [Room]. It will load a level by provided ID and start new [Room] with a [pc.Application]. 55 | 56 | | Param | Type | Description | 57 | | --- | --- | --- | 58 | | levelId | `number` | ID Number of a level. | 59 | | tickrate (optional) | `number` | Tick rate - is how many times Application will be calling `update` in a second. Defaults to 20 UPS. | 60 | 61 | 62 | 63 | ### get(id) 64 | 65 | **Returns:** [Room] | `null` 66 | Get a [Room] by ID. 67 | 68 | | Param | Type | Description | 69 | | --- | --- | --- | 70 | | id | `number` | ID of a [Room]. | 71 | 72 | 73 | 74 | ### has(id) 75 | 76 | **Returns:** `boolean` 77 | Check if a [Room] with a specific ID exists. 78 | 79 | | Param | Type | Description | 80 | | --- | --- | --- | 81 | | id | `number` | ID of a [Room]. | 82 | 83 | 84 | 85 | 86 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 87 | [Room]: ./Room.md 88 | [pc.Application]: https://developer.playcanvas.com/en/api/pc.Application.html 89 | [User]: ./User.md 90 | -------------------------------------------------------------------------------- /tests/mock/templates/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Box", 3 | "tags": [], 4 | "meta": null, 5 | "data": { 6 | "entities": { 7 | "e1c81cb0-c8f0-465b-b1dc-c984a7dbb07b": { 8 | "name": "Box", 9 | "tags": [], 10 | "enabled": true, 11 | "resource_id": "e1c81cb0-c8f0-465b-b1dc-c984a7dbb07b", 12 | "parent": null, 13 | "children": [], 14 | "position": [0, 0.5, 0], 15 | "rotation": [0, 0, 0], 16 | "scale": [1, 1, 1], 17 | "components": { 18 | "render": { 19 | "enabled": true, 20 | "type": "box", 21 | "asset": null, 22 | "materialAssets": [null], 23 | "layers": [0], 24 | "batchGroupId": null, 25 | "castShadows": true, 26 | "castShadowsLightmap": true, 27 | "receiveShadows": true, 28 | "lightmapped": false, 29 | "lightmapSizeMultiplier": 1, 30 | "castShadowsLightMap": true, 31 | "lightMapped": false, 32 | "lightMapSizeMultiplier": 1, 33 | "isStatic": false, 34 | "rootBone": null 35 | }, 36 | "script": { 37 | "enabled": true, 38 | "order": ["networkEntity"], 39 | "scripts": { 40 | "networkEntity": { 41 | "enabled": true, 42 | "attributes": { 43 | "id": 0, 44 | "owner": "", 45 | "properties": [] 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | }, 54 | "file": null, 55 | "path": [], 56 | "id": 92407117, 57 | "uniqueId": 92407117, 58 | "type": "template", 59 | "source": false, 60 | "source_asset_id": null, 61 | "createdAt": "2022-07-07T13:32:40.500Z", 62 | "scope": { 63 | "type": "project", 64 | "id": 857037 65 | }, 66 | "user_id": 4373, 67 | "revision": 1, 68 | "preload": true, 69 | "exclude": false, 70 | "region": "eu-west-1", 71 | "task": null, 72 | "i18n": {} 73 | } 74 | -------------------------------------------------------------------------------- /docs/client/InterpolateValue.md: -------------------------------------------------------------------------------- 1 | # InterpolateValue (client) 2 | 3 | Helper class to interpolate values between states. It has mechanics to smoothen unreliable intervals of state and can interpolate simple values such as `number`, as well as complex: [pc.Vec2], [pc.Vec3], [pc.Vec4], [pc.Quat], [pc.Color]. 4 | 5 | --- 6 | 7 | # Index 8 | 9 | ### Properties 10 | 11 | .value : `number` | [pc.Vec2] | [pc.Vec3] | [pc.Vec4] | [pc.Quat] | [pc.Color] 12 | 13 | ### Constructor 14 | new InterpolateValue(value) 15 | 16 | ### Functions 17 | 18 | set(value) 19 | add(value) 20 | update(dt) 21 | 22 | 23 | --- 24 | 25 | 26 | # Properties 27 | 28 | 29 | ### .value : `number` | [pc.Vec2] | [pc.Vec3] | [pc.Vec4] | [pc.Quat] | [pc.Color] 30 | Current Value, that it interpolated between states on every update. 31 | 32 | 33 | # Constructor 34 | 35 | 36 | ### new InterpolateValue(value) 37 | 38 | 39 | | Param | Type | Description | 40 | | --- | --- | --- | 41 | | value | `number` | [pc.Vec2] | [pc.Vec3] | [pc.Vec4] | [pc.Quat] | [pc.Color] | Value to interpolate. Can be a simple `number`, as well as complex: [pc.Vec2], [pc.Vec3], [pc.Vec4], [pc.Quat], [pc.Color] object with `lerp` or `slerp`, `copy` and `clone` method. | 42 | 43 | 44 | 45 | # Functions 46 | 47 | 48 | ### set(value) 49 | 50 | Force a value set, ignoring an interpolation. 51 | 52 | | Param | Type | 53 | | --- | --- | 54 | | value | `number` | [pc.Vec2] | [pc.Vec3] | [pc.Vec4] | [pc.Quat] | [pc.Color] | 55 | 56 | 57 | 58 | ### add(value) 59 | 60 | Add a value to list of interpolation states. 61 | 62 | | Param | Type | 63 | | --- | --- | 64 | | value | `number` | [pc.Vec2] | [pc.Vec3] | [pc.Vec4] | [pc.Quat] | [pc.Color] | 65 | 66 | 67 | 68 | ### update(dt) 69 | 70 | Call an update, with should be called at the application update interval. This will progress interpolation through states based on Delta Time. 71 | 72 | | Param | Type | Description | 73 | | --- | --- | --- | 74 | | dt | `number` | Delta Time of an application update frequency. | 75 | 76 | 77 | 78 | 79 | [pc.Vec2]: https://developer.playcanvas.com/en/api/pc.Vec2.html 80 | [pc.Vec3]: https://developer.playcanvas.com/en/api/pc.Vec3.html 81 | [pc.Vec4]: https://developer.playcanvas.com/en/api/pc.Vec4.html 82 | [pc.Quat]: https://developer.playcanvas.com/en/api/pc.Quat.html 83 | [pc.Color]: https://developer.playcanvas.com/en/api/pc.Color.html 84 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | # Server 4 | 5 | 6 | ### PlayNetwork 7 | Main interface of PlayNetwork server. This class handles clients connection and communication. 8 | 9 | ### NetworkEntity 10 | NetworkEntity is a [pc.ScriptType], which is attached to a [pc.ScriptComponent] of an [pc.Entity] that needs to be synchronised between server and clients. It has unique ID, optional owner and list of properties to be synchronised. For convenience, [pc.Entity] has additional property: `entity.networkEntity`. 11 | 12 | ### Room 13 | A Room represents own [pc.Application] context, with a list of joined [User](./server/User.md)s. 14 | 15 | ### Rooms 16 | Interface with a list of server [Room](./server/Room.md)s and an interface to create new rooms. 17 | 18 | ### User 19 | User interface which is created for each individual connection and inter-connections to a [PlayNetwork](./server/PlayNetwork.md). 20 | 21 | ### Users 22 | Interface of all [User](./server/User.md)s currently connected to a server. As well as for handling new user authentication. 23 | 24 | 25 | 26 | 27 | # Client 28 | 29 | 30 | ### PlayNetwork 31 | Main interface to connect to a server and interact with networked data. 32 | 33 | ### InterpolateValue 34 | Helper class to interpolate values between states. It has mechanics to smoothen unreliable intervals of state and can interpolate simple values such as `number`, as well as complex: [pc.Vec2], [pc.Vec3], [pc.Vec4], [pc.Quat], [pc.Color]. 35 | 36 | ### Levels 37 | Interface that allows to save hierarchy data to a server. 38 | 39 | ### NetworkEntity 40 | NetworkEntity is a [pc.ScriptType], which is attached to a [pc.ScriptComponent] of an [pc.Entity] that needs to be synchronised between server and clients. It has unique ID, optional owner and list of properties to be synchronised. For convenience, [pc.Entity] has additional property: `entity.networkEntity`. 41 | 42 | ### Room 43 | Room to which [User](./client/User.md) has joined. 44 | 45 | ### User 46 | User object that is created for each [User](./client/User.md) we know, including ourself. 47 | 48 | 49 | 50 | [pc.ScriptType]: https://developer.playcanvas.com/en/api/pc.ScriptType.html 51 | [pc.ScriptComponent]: https://developer.playcanvas.com/en/api/pc.ScriptComponent.html 52 | [pc.Entity]: https://developer.playcanvas.com/en/api/pc.Entity.html 53 | [pc.Application]: https://developer.playcanvas.com/en/api/pc.Application.html 54 | [pc.Vec2]: https://developer.playcanvas.com/en/api/pc.Vec2.html 55 | [pc.Vec3]: https://developer.playcanvas.com/en/api/pc.Vec3.html 56 | [pc.Vec4]: https://developer.playcanvas.com/en/api/pc.Vec4.html 57 | [pc.Quat]: https://developer.playcanvas.com/en/api/pc.Quat.html 58 | [pc.Color]: https://developer.playcanvas.com/en/api/pc.Color.html 59 | -------------------------------------------------------------------------------- /src/server/core/rooms.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | import levels from '../libs/levels.js'; 3 | 4 | import pn from './../index.js'; 5 | import Room from './room.js'; 6 | 7 | /** 8 | * @class Rooms 9 | * @classdesc Interface with a list of server {@link Room}s and an interface to create new rooms. 10 | * @extends pc.EventHandler 11 | */ 12 | 13 | /** 14 | * @event Rooms#join 15 | * @description Fired when a {@link User} successfully joined a {@link Room}. 16 | * @async 17 | * @param {Room} room {@link Room} to which a {@link User} joined. 18 | * @param {User} user {@link User} who joined. 19 | */ 20 | 21 | /** 22 | * @event Rooms#leave 23 | * @description Fired when a {@link User} left a {@link Room}. 24 | * @param {Room} room {@link Room} from which a {@link User} has left. 25 | * @param {User} user {@link User} who has left. 26 | */ 27 | 28 | class Rooms extends pc.EventHandler { 29 | _index = new Map(); 30 | 31 | initialize() { 32 | pn.on('_room:create', async (sender, data, callback) => { 33 | if (!data?.levelId) return callback(new Error('No levelId provided')); 34 | if (!(await levels.has(data?.levelId))) return callback(new Error('Level not found')); 35 | 36 | const room = await this.create(data.levelId, data.tickrate); 37 | callback(null, room.id); 38 | }); 39 | 40 | pn.on('_room:join', async (sender, id, callback) => { 41 | if (!id) return callback(new Error('Room id is required')); 42 | // eslint-disable-next-line n/no-callback-literal 43 | callback(await sender.join(id)); 44 | }); 45 | 46 | pn.on('_room:leave', async (sender, _, callback) => { 47 | // eslint-disable-next-line n/no-callback-literal 48 | callback(await sender.leave()); 49 | }); 50 | } 51 | 52 | /** 53 | * @function create 54 | * @description Function to create a new {@link Room}. 55 | * It will load a level by provided ID and start new {@link Room} with a {@link pc.Application}. 56 | * @async 57 | * @param {number} levelId ID Number of a level. 58 | * @param {number} [tickrate=20] Tick rate - is how many times Application 59 | * will be calling `update` in a second. Defaults to 20 UPS. 60 | * @returns {Room} Room that has been created. 61 | */ 62 | async create(levelId, tickrate = 20) { 63 | const roomId = await pn.generateId('room'); 64 | 65 | const room = new Room(roomId, tickrate); 66 | await room.initialize(levelId); 67 | 68 | this._index.set(room.id, room); 69 | 70 | room.once('destroy', () => { 71 | this._index.delete(room.id); 72 | }); 73 | 74 | return room; 75 | } 76 | 77 | /** 78 | * @method get 79 | * @description Get a {@link Room} by ID. 80 | * @param {number} id ID of a {@link Room}. 81 | * @returns {Room|null} 82 | */ 83 | get(id) { 84 | return this._index.get(id) || null; 85 | } 86 | 87 | /** 88 | * @method has 89 | * @description Check if a {@link Room} with a specific ID exists. 90 | * @param {number} id ID of a {@link Room}. 91 | * @returns {boolean} 92 | */ 93 | has(id) { 94 | return this._index.has(id); 95 | } 96 | } 97 | 98 | export default Rooms; 99 | -------------------------------------------------------------------------------- /docs/client/NetworkEntity.md: -------------------------------------------------------------------------------- 1 | # NetworkEntity (client) 2 | extends [pc.ScriptType] 3 | 4 | NetworkEntity is a [pc.ScriptType], which is attached to a [pc.ScriptComponent] of an [pc.Entity] that needs to be synchronised between server and clients. It has unique ID, optional owner and list of properties to be synchronised. For convenience, [pc.Entity] has additional property: `entity.networkEntity`. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `string` 13 | .mine : `boolean` 14 | .properties : `Array.` 15 | 16 | ### Events 17 | 18 | * => ([data]) 19 | 20 | ### Functions 21 | 22 | send(name, [data], [callback]) 23 | 24 | 25 | --- 26 | 27 | 28 | # Properties 29 | 30 | 31 | ### .id : `string` 32 | Unique identifier. 33 | 34 | 35 | ### .mine : `boolean` 36 | Whether this entity is related to our User. 37 | 38 | 39 | ### .properties : `Array.` 40 | List of properties, which should be synchronised and optionally can be interpolated. Each property `object` has these properties: 41 | 42 | | Param | Type | Description | 43 | | --- | --- | --- | 44 | | path | `string` | Path to a property. | 45 | | interpolate | `boolean` | If value is type of: `number` | `Vec2` | `Vec3` | `Vec4` | `Quat` | `Color`, then it can be interpolated. | 46 | | ignoreForOwner | `boolean` | If `true` then server will not send this property updates to an owner. | 47 | 48 | 49 | 50 | # Events 51 | 52 | 53 | ### * [event] => ([data]) 54 | [NetworkEntity] will receive own named network messages. 55 | 56 | | Param | Type | Description | 57 | | --- | --- | --- | 58 | | data | `object` | `array` | `string` | `number` | `boolean` | Message data. | 59 | 60 | 61 | # Functions 62 | 63 | 64 | ### send(name, [data], [callback]) 65 | 66 | Send named message to a server related to this NetworkEntity. 67 | 68 | | Param | Type | Description | 69 | | --- | --- | --- | 70 | | name | `string` | Name of a message. | 71 | | data (optional) | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 72 | | callback (optional) | responseCallback | Callback that will be fired when response message is received. | 73 | 74 | 75 | 76 | # Callbacks 77 | 78 | 79 | ### responseCallback [callback] => (error, data) 80 | 81 | | Param | Type | Description | 82 | | --- | --- | --- | 83 | | error | ````null```` | ```[Error]``` | Error provided with with a response. | 84 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 85 | 86 | 87 | 88 | 89 | [pc.ScriptType]: https://developer.playcanvas.com/en/api/pc.ScriptType.html 90 | [NetworkEntity]: ./NetworkEntity.md 91 | [pc.ScriptComponent]: https://developer.playcanvas.com/en/api/pc.ScriptComponent.html 92 | [pc.Entity]: https://developer.playcanvas.com/en/api/pc.Entity.html 93 | -------------------------------------------------------------------------------- /src/server/libs/performance.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | 3 | class Performance extends pc.EventHandler { 4 | constructor() { 5 | super(); 6 | 7 | this._pingIds = 1; 8 | 9 | this._cpuLoad = 0; 10 | this._lastCpuLoad = process.cpuUsage(); 11 | this._lastCpuLoadCheck = Date.now(); 12 | 13 | this._memory = 0; 14 | 15 | this._updateInterval = setInterval(() => { 16 | const load = process.cpuUsage(); 17 | const loadDelta = (load.user - this._lastCpuLoad.user) + (load.system - this._lastCpuLoad.system); 18 | const time = Date.now(); 19 | const timeDelta = (time - this._lastCpuLoadCheck) * 1000; 20 | 21 | this._cpuLoad = loadDelta / timeDelta; 22 | this._lastCpuLoad = load; 23 | this._lastCpuLoadCheck = time; 24 | 25 | this._memory = process.memoryUsage.rss(); 26 | }, 1000); 27 | } 28 | 29 | addCpuLoad(scope) { 30 | Object.defineProperty(scope, 'cpuLoad', { get: () => this._cpuLoad, configurable: true }); 31 | } 32 | 33 | addMemoryUsage(scope) { 34 | Object.defineProperty(scope, 'memory', { get: () => this._memory, configurable: true }); 35 | } 36 | 37 | addLatency(user) { 38 | user.latency = 0; 39 | 40 | user._pings = new Map(); 41 | user._pingInterval = setInterval(() => { 42 | const id = this._pingIds++; 43 | user._pings.set(id, { scope: user, timestamp: Date.now() }); 44 | user.send('_ping', { id, l: user.latency }, 'user'); 45 | }, 1000); 46 | 47 | user.on('_pong', (from, id) => { 48 | if (from !== user) return; 49 | 50 | const ping = user._pings.get(id); 51 | if (!ping) return; 52 | 53 | user.latency = Date.now() - ping.timestamp; 54 | user._pings.delete(id); 55 | }); 56 | } 57 | 58 | addRoomLatency(room) { 59 | room._lastPings = 0; 60 | room._pings = new Map(); 61 | 62 | room.on('_pong', (user) => { 63 | const ping = room._pings.get(user.id); 64 | if (ping) ping.pong = true; 65 | }); 66 | } 67 | 68 | handlePings(room) { 69 | const lastPings = room._lastPings; 70 | const now = Date.now(); 71 | 72 | for (const ping of room._pings.values()) { 73 | if (!ping.pong || ping.latency) continue; 74 | ping.latency = now - ping.timestamp; 75 | } 76 | 77 | if (now - lastPings < 1000) return; 78 | 79 | for (const user of room.users.values()) { 80 | const lastPing = room._pings.get(user.id); 81 | if (lastPing && !lastPing.pong) continue; 82 | 83 | const ping = { timestamp: now, pong: false, latency: 0, roomId: room.id }; 84 | room._pings.set(user.id, ping); 85 | 86 | user.send('_ping', { r: room.id, l: lastPing?.latency || 0 }); 87 | } 88 | 89 | room._lastPings = now; 90 | } 91 | 92 | removeLatency(user) { 93 | clearInterval(user._pingInterval); 94 | user.off('_pong'); 95 | delete user._pingInterval; 96 | delete user._pings; 97 | delete user.latency; 98 | } 99 | 100 | removeRoomLatency(room) { 101 | room.off('_pong'); 102 | delete room._pings; 103 | delete room._lastPings; 104 | } 105 | } 106 | 107 | export default new Performance(); 108 | -------------------------------------------------------------------------------- /docs/server/NetworkEntity.md: -------------------------------------------------------------------------------- 1 | # NetworkEntity (server) 2 | extends [pc.ScriptType] 3 | 4 | NetworkEntity is a [pc.ScriptType], which is attached to a [pc.ScriptComponent] of an [pc.Entity] that needs to be synchronised between server and clients. It has unique ID, optional owner and list of properties to be synchronised. For convenience, [pc.Entity] has additional property: `entity.networkEntity`. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `string` 13 | .user : [User] 14 | .properties : `Array.` 15 | 16 | ### Events 17 | 18 | * => (sender, [data], callback) 19 | 20 | ### Functions 21 | 22 | send(name, [data]) 23 | 24 | 25 | --- 26 | 27 | 28 | # Properties 29 | 30 | 31 | ### .id : `string` 32 | Unique identifier within a server. 33 | 34 | 35 | ### .user : [User] 36 | Optional [User] to which this [pc.Entity] is related. 37 | 38 | 39 | ### .properties : `Array.` 40 | List of properties, which should be synchronised and optionally can be interpolated. Each property `object` has these properties: 41 | 42 | | Param | Type | Description | 43 | | --- | --- | --- | 44 | | path | `string` | Path to a property. | 45 | | interpolate | `boolean` | If value is type of: `number` | `Vec2` | `Vec3` | `Vec4` | `Quat` | `Color`, then it can be interpolated. | 46 | | ignoreForOwner | `boolean` | If `true` then server will not send this property updates to its related user. | 47 | 48 | 49 | 50 | # Events 51 | 52 | 53 | ### * [event] => (sender, [data], callback) 54 | [NetworkEntity] will receive own named network messages. 55 | 56 | | Param | Type | Description | 57 | | --- | --- | --- | 58 | | sender | [User] | [User] that sent the message. | 59 | | data | `object` | `array` | `string` | `number` | `boolean` | Message data. | 60 | | callback | responseCallback | Callback that can be called to respond to a message. | 61 | 62 | 63 | # Functions 64 | 65 | 66 | ### send(name, [data]) 67 | 68 | Send a named message to a [NetworkEntity]. It will be received by all clients that know about this NetworkEntity. 69 | 70 | | Param | Type | Description | 71 | | --- | --- | --- | 72 | | name | `string` | Name of a message. | 73 | | data (optional) | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 74 | 75 | 76 | 77 | # Callbacks 78 | 79 | 80 | ### responseCallback [callback] => (error, data) 81 | 82 | | Param | Type | Description | 83 | | --- | --- | --- | 84 | | error | ````null```` | ```[Error]``` | Error provided with a response. | 85 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 86 | 87 | 88 | 89 | 90 | [pc.ScriptType]: https://developer.playcanvas.com/en/api/pc.ScriptType.html 91 | [NetworkEntity]: ./NetworkEntity.md 92 | [User]: ./User.md 93 | [pc.ScriptComponent]: https://developer.playcanvas.com/en/api/pc.ScriptComponent.html 94 | [pc.Entity]: https://developer.playcanvas.com/en/api/pc.Entity.html 95 | -------------------------------------------------------------------------------- /docs/client/Room.md: -------------------------------------------------------------------------------- 1 | # Room (client) 2 | extends [pc.EventHandler] 3 | 4 | Room to which [User] has joined. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `number` 13 | .tickrate : `number` 14 | .root : [pc.Entity] 15 | .latency : `number` 16 | 17 | ### Events 18 | 19 | join => (user) 20 | leave => (user) 21 | destroy 22 | * => ([data]) 23 | 24 | ### Functions 25 | 26 | send(name, [data], [callback]) 27 | 28 | 29 | --- 30 | 31 | 32 | # Properties 33 | 34 | 35 | ### .id : `number` 36 | Numerical ID. 37 | 38 | 39 | ### .tickrate : `number` 40 | Server tickrate of this [Room]. 41 | 42 | 43 | ### .root : [pc.Entity] 44 | Root [pc.Entity] of this [Room]. 45 | 46 | 47 | ### .latency : `number` 48 | Latency of this [Room] that takes in account network latency and server application update frequency. 49 | 50 | 51 | 52 | # Events 53 | 54 | 55 | ### join [event] => (user) 56 | Fired when [User] has joined a [Room]. 57 | 58 | | Param | Type | Description | 59 | | --- | --- | --- | 60 | | user | [User] | [User] that is associated with this [Room]. | 61 | 62 | 63 | 64 | ### leave [event] => (user) 65 | Fired when [User] has left a [Room]. 66 | 67 | | Param | Type | Description | 68 | | --- | --- | --- | 69 | | user | [User] | [User] that was associated with this [Room]. | 70 | 71 | 72 | 73 | ### destroy [event] 74 | Fired when [Room] has been destroyed. 75 | 76 | 77 | 78 | 79 | ### * [event] => ([data]) 80 | Fired when a [Room] received a named network message. 81 | 82 | | Param | Type | Description | 83 | | --- | --- | --- | 84 | | data | `null` | `object` | `array` | `string` | `number` | `boolean` | Message data. | 85 | 86 | 87 | # Functions 88 | 89 | 90 | ### send(name, [data], [callback]) 91 | 92 | Send named message to a Room. 93 | 94 | | Param | Type | Description | 95 | | --- | --- | --- | 96 | | name | `string` | Name of a message. | 97 | | data (optional) | `null` | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 98 | | callback (optional) | responseCallback | Callback that will be fired when response message is received. | 99 | 100 | 101 | 102 | # Callbacks 103 | 104 | 105 | ### responseCallback [callback] => (error, data) 106 | 107 | | Param | Type | Description | 108 | | --- | --- | --- | 109 | | error | ````null```` | ```[Error]``` | Error provided with with a response. | 110 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 111 | 112 | 113 | 114 | 115 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 116 | [User]: ./User.md 117 | [Room]: ./Room.md 118 | [pc.Entity]: https://developer.playcanvas.com/en/api/pc.Entity.html 119 | -------------------------------------------------------------------------------- /src/client/levels.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Levels 3 | * @classdesc Interface that allows to save hierarchy data to a server. 4 | */ 5 | 6 | class Levels { 7 | constructor() { 8 | if (pc.Entity.prototype.hasOwnProperty('room')) return this; 9 | 10 | Object.defineProperty(pc.Entity.prototype, 'room', { 11 | get: function() { 12 | if (!this._room) { 13 | let parent = this.parent; 14 | while (parent && !this._room) { 15 | if (parent._room) { 16 | this._room = parent._room; 17 | break; 18 | } else { 19 | parent = parent.parent; 20 | } 21 | } 22 | } 23 | 24 | return this._room; 25 | } 26 | }); 27 | } 28 | 29 | /** 30 | * @method save 31 | * @description Save the hierarchy data of a Scene to the server. 32 | * @param {Number} sceneId ID of a Scene. 33 | * @param {errorCallback} [callback] Callback of a server response. 34 | */ 35 | save(sceneId, callback) { 36 | this._getEditorSceneData(sceneId, (level) => { 37 | pn.send('_level:save', level, callback); 38 | }); 39 | } 40 | 41 | async build(room, level) { 42 | const sceneRegistryItem = new pc.SceneRegistryItem(level.name, level.item_id); 43 | sceneRegistryItem.data = level; 44 | sceneRegistryItem._loading = false; 45 | 46 | return new Promise((resolve) => { 47 | this._loadSceneHierarchy.call(pc.app.scenes, sceneRegistryItem, room, () => { 48 | pc.app.scenes.loadSceneSettings(sceneRegistryItem, resolve); 49 | }); 50 | }); 51 | } 52 | 53 | _getEditorSceneData(sceneId, callback) { 54 | pc.app.loader._handlers.scene.load(sceneId.toString(), (err, scene) => { 55 | if (err) { 56 | console.error(err); 57 | return; 58 | } 59 | 60 | callback(scene); 61 | }); 62 | } 63 | 64 | _loadSceneHierarchy(sceneItem, room, callback) { 65 | const self = this; 66 | 67 | // Because we need to load scripts before we instance the hierarchy (i.e. before we create script components) 68 | // Split loading into load and open 69 | const handler = this._app.loader.getHandler("hierarchy"); 70 | 71 | this._loadSceneData(sceneItem, false, function(err, sceneItem) { 72 | if (err) return; 73 | 74 | const url = sceneItem.url; 75 | const data = sceneItem.data; 76 | 77 | // called after scripts are preloaded 78 | const _loaded = function() { 79 | self._app.systems.script.preloading = true; 80 | const entity = handler.open(url, data); 81 | 82 | self._app.systems.script.preloading = false; 83 | 84 | // clear from cache because this data is modified by entity operations (e.g. destroy) 85 | self._app.loader.clearCache(url, "hierarchy"); 86 | 87 | // add to hierarchy 88 | self._app.root.addChild(entity); 89 | 90 | entity._room = room; 91 | room.root = entity; 92 | 93 | // initialize components 94 | self._app.systems.fire('initialize', entity); 95 | self._app.systems.fire('postInitialize', entity); 96 | self._app.systems.fire('postPostInitialize', entity); 97 | callback(); 98 | }; 99 | 100 | // load priority and referenced scripts before opening scene 101 | self._app._preloadScripts(data, _loaded); 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /tests/messages.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | 3 | import pn from './../src/server/index.js'; 4 | 5 | import { circularJson, testJson } from './utils/constants.js'; 6 | 7 | export const clientMessages = () => { 8 | describe('Client messages', () => { 9 | test('Client sent valid server message', (done) => { 10 | pn.once('test', (from, data) => { 11 | try { 12 | expect(from.id).toBe(global.serverUser.id); 13 | expect(data).toEqual(testJson); 14 | } finally { 15 | done(); 16 | } 17 | }); 18 | 19 | global.client.send('test', testJson); 20 | }); 21 | 22 | test('Client sent valid user message', (done) => { 23 | global.serverUser.once('test', (from, data) => { 24 | try { 25 | expect(from.id).toBe(global.serverUser.id); 26 | expect(data).toEqual(testJson); 27 | } finally { 28 | done(); 29 | } 30 | }); 31 | 32 | global.client.me.send('test', testJson); 33 | }); 34 | 35 | test('Client sent invalid server message', () => { 36 | expect(() => { 37 | global.client.send('test', circularJson); 38 | }).toThrow(); 39 | }); 40 | 41 | test('Client sent callback', (done) => { 42 | pn.once('test', (from, data, callback) => { 43 | expect(typeof callback).toBe('function'); 44 | callback(null, data); 45 | }); 46 | 47 | global.client.me.send('test', testJson, (err, data) => { 48 | try { 49 | expect(err).toBeNull(); 50 | expect(data).toEqual(testJson); 51 | } finally { 52 | done(); 53 | } 54 | }); 55 | }); 56 | 57 | test('Client tried to fire destroy', (done) => { 58 | global.client.me.send('destroy', testJson, (err) => { 59 | try { 60 | expect(err).toBeTruthy(); 61 | expect(err.message).toBe('Event destroy is reserved'); 62 | } finally { 63 | done(); 64 | } 65 | }); 66 | }); 67 | 68 | test('Server returned error', (done) => { 69 | pn.once('test', (from, data, callback) => { 70 | callback(new Error('test')); 71 | }); 72 | 73 | global.client.me.send('test', testJson, (err, data) => { 74 | try { 75 | expect(err).toBeTruthy(); 76 | expect(err.message).toEqual('test'); 77 | expect(data).toBeNull(); 78 | } finally { 79 | done(); 80 | } 81 | }); 82 | }); 83 | }); 84 | }; 85 | 86 | export const serverMessages = () => { 87 | describe('Server messages', () => { 88 | test('Server sent valid global message', (done) => { 89 | global.client.once('test', (data) => { 90 | try { 91 | expect(data).toEqual(testJson); 92 | } finally { 93 | done(); 94 | } 95 | }); 96 | 97 | global.serverUser.send('test', testJson); 98 | }); 99 | 100 | test('Server sent valid user message', (done) => { 101 | global.client.me.once('test', (data) => { 102 | try { 103 | expect(data).toEqual(testJson); 104 | } finally { 105 | done(); 106 | } 107 | }); 108 | 109 | global.serverUser.send('test', testJson); 110 | }); 111 | 112 | test('Server sent invalid global message', () => { 113 | expect(() => { 114 | global.serverUser.send('test', circularJson); 115 | }).toThrow(); 116 | }); 117 | }); 118 | }; 119 | -------------------------------------------------------------------------------- /docs/server/Room.md: -------------------------------------------------------------------------------- 1 | # Room (server) 2 | extends [pc.EventHandler] 3 | 4 | A Room represents own [pc.Application] context, with a list of joined [User]s. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `number` 13 | .app : [pc.Application] 14 | .users : [Map]<`number`, [User]> 15 | 16 | ### Events 17 | 18 | initialize 19 | join => (user) 20 | leave => (user) 21 | error => (error) 22 | destroy 23 | * => (sender, [data], callback) 24 | 25 | ### Functions 26 | 27 | send(name, [data]) 28 | 29 | 30 | --- 31 | 32 | 33 | # Properties 34 | 35 | 36 | ### .id : `number` 37 | Unique ID of a [Room]. 38 | 39 | 40 | ### .app : [pc.Application] 41 | PlayCanvas [pc.Application] associated with a [Room]. 42 | 43 | 44 | ### .users : [Map]<`number`, [User]> 45 | Map of joined [User]s to a room. Indexed by a user ID. 46 | 47 | 48 | 49 | # Events 50 | 51 | 52 | ### initialize [event] 53 | Fired when [Room] has been loaded, initialized, and [pc.Application] started. 54 | 55 | 56 | 57 | 58 | ### join [event] => (user) 59 | Fired when [User] has joined a [Room]. 60 | 61 | | Param | Type | 62 | | --- | --- | 63 | | user | [User] | 64 | 65 | 66 | 67 | ### leave [event] => (user) 68 | Fired when [User] has left a [Room]. 69 | 70 | | Param | Type | 71 | | --- | --- | 72 | | user | [User] | 73 | 74 | 75 | 76 | ### error [event] => (error) 77 | Fired when [pc.Application] throws an error. This is a good place to handle gameplay errors. 78 | 79 | | Param | Type | Description | 80 | | --- | --- | --- | 81 | | error | [Error] | [Error] object. | 82 | 83 | 84 | 85 | ### destroy [event] 86 | Fired when [Room] has been destroyed. 87 | 88 | 89 | 90 | 91 | ### * [event] => (sender, [data], callback) 92 | [Room] will receive own named network messages. 93 | 94 | | Param | Type | Description | 95 | | --- | --- | --- | 96 | | sender | [User] | [User] that sent the message. | 97 | | data | `object` | `array` | `string` | `number` | `boolean` | Message data. | 98 | | callback | responseCallback | Callback that can be called to respond to a message. | 99 | 100 | 101 | # Functions 102 | 103 | 104 | ### send(name, [data]) 105 | 106 | Send named message to every [User] in this Room. 107 | 108 | | Param | Type | Description | 109 | | --- | --- | --- | 110 | | name | `string` | Name of a message. | 111 | | data (optional) | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 112 | 113 | 114 | 115 | # Callbacks 116 | 117 | 118 | ### responseCallback [callback] => (error, data) 119 | 120 | | Param | Type | Description | 121 | | --- | --- | --- | 122 | | error | ````null```` | ```[Error]``` | Error provided with a response. | 123 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 124 | 125 | 126 | 127 | 128 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 129 | [User]: ./User.md 130 | [Room]: ./Room.md 131 | [pc.Application]: https://developer.playcanvas.com/en/api/pc.Application.html 132 | [Error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error 133 | [Map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map 134 | -------------------------------------------------------------------------------- /docs/server/User.md: -------------------------------------------------------------------------------- 1 | # User (server) 2 | extends [pc.EventHandler] 3 | 4 | User interface which is created for each individual connection and inter-connections to a [PlayNetwork]. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `number` | `string` 13 | .room : `null` | [Room] 14 | .latency : `number` 15 | 16 | ### Events 17 | 18 | join => (room) 19 | leave => (room) 20 | destroy 21 | * => (sender, [data], callback) 22 | 23 | ### Functions 24 | 25 | join(roomId) [async] => `null` | [Error] 26 | leave() [async] => `null` | [Error] 27 | send(name, [data]) 28 | 29 | 30 | --- 31 | 32 | 33 | # Properties 34 | 35 | 36 | ### .id : `number` | `string` 37 | Unique identifier for the user. 38 | 39 | 40 | ### .room : `null` | [Room] 41 | [Room] that [User] is currently joined to. 42 | 43 | 44 | ### .latency : `number` 45 | Latency of the connection in milliseconds. 46 | 47 | 48 | 49 | # Events 50 | 51 | 52 | ### join [event] => (room) 53 | Fired when [User] is joined to a [Room]. 54 | 55 | | Param | Type | Description | 56 | | --- | --- | --- | 57 | | room | [Room] | [Room] that [User] is joined. | 58 | 59 | 60 | 61 | ### leave [event] => (room) 62 | Fired when [User] left [Room]. 63 | 64 | | Param | Type | Description | 65 | | --- | --- | --- | 66 | | room | [Room] | [Room] that [User] left. | 67 | 68 | 69 | 70 | ### destroy [event] 71 | Fired after disconnect and related data is destroyed. 72 | 73 | 74 | 75 | 76 | ### * [event] => (sender, [data], callback) 77 | [User] will receive own named network messages. 78 | 79 | | Param | Type | Description | 80 | | --- | --- | --- | 81 | | sender | [User] | [User] that sent the message. | 82 | | data | `object` | `array` | `string` | `number` | `boolean` | Message data. | 83 | | callback | responseCallback | Callback that can be called to respond to a message. | 84 | 85 | 86 | # Functions 87 | 88 | 89 | ### join(roomId) [async] 90 | 91 | **Returns:** `null` | [Error] 92 | Join to a [Room]. 93 | 94 | | Param | Type | Description | 95 | | --- | --- | --- | 96 | | roomId | `number` | ID of the [Room] to join. | 97 | 98 | 99 | 100 | ### leave() [async] 101 | 102 | **Returns:** `null` | [Error] 103 | Leave a [Room] to which is currently joined. 104 | 105 | 106 | 107 | 108 | ### send(name, [data]) 109 | 110 | Send a named message to a [User]. 111 | 112 | | Param | Type | Description | 113 | | --- | --- | --- | 114 | | name | `string` | Name of a message. | 115 | | data (optional) | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 116 | 117 | 118 | 119 | # Callbacks 120 | 121 | 122 | ### responseCallback [callback] => (error, data) 123 | 124 | | Param | Type | Description | 125 | | --- | --- | --- | 126 | | error | ````null```` | ```[Error]``` | Error provided with a response. | 127 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 128 | 129 | 130 | 131 | 132 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 133 | [Room]: ./Room.md 134 | [Error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error 135 | [User]: ./User.md 136 | [PlayNetwork]: ./PlayNetwork.md 137 | -------------------------------------------------------------------------------- /docs/server/PlayNetwork.md: -------------------------------------------------------------------------------- 1 | # PlayNetwork (server) 2 | extends [pc.EventHandler] 3 | 4 | Main interface of PlayNetwork server. This class handles clients connection and communication. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .id : `number` 13 | .users : [Users] 14 | .rooms : [Rooms] 15 | .networkEntities : [Map]<`number`, [NetworkEntity]> 16 | .cpuLoad : `number` 17 | .memory : `number` 18 | 19 | ### Events 20 | 21 | error => (error) 22 | * => (sender, [data], callback) 23 | 24 | ### Functions 25 | 26 | start(settings) [async] 27 | 28 | 29 | --- 30 | 31 | 32 | # Properties 33 | 34 | 35 | ### .id : `number` 36 | Numerical ID of the server. 37 | 38 | 39 | ### .users : [Users] 40 | [Users] interface that stores all connected users. 41 | 42 | 43 | ### .rooms : [Rooms] 44 | [Rooms] interface that stores all rooms and handles new [Rooms] creation. 45 | 46 | 47 | ### .networkEntities : [Map]<`number`, [NetworkEntity]> 48 | Map of all [NetworkEntity]s created by this server. 49 | 50 | 51 | ### .cpuLoad : `number` 52 | Current CPU load 0..1. 53 | 54 | 55 | ### .memory : `number` 56 | Current memory usage in bytes. 57 | 58 | 59 | 60 | # Events 61 | 62 | 63 | ### error [event] => (error) 64 | Unhandled error. 65 | 66 | | Param | Type | Description | 67 | | --- | --- | --- | 68 | | error | [Error] | [Error] object. | 69 | 70 | 71 | 72 | ### * [event] => (sender, [data], callback) 73 | [PlayNetwork] will receive own named network messages. Those messages are sent by the clients. 74 | 75 | | Param | Type | Description | 76 | | --- | --- | --- | 77 | | sender | [User] | User that sent the message. | 78 | | data | `object` | `array` | `string` | `number` | `boolean` | Message data. | 79 | | callback | responseCallback | Callback that can be called to respond to a message. | 80 | 81 | 82 | # Functions 83 | 84 | 85 | ### start(settings) [async] 86 | 87 | Start PlayNetwork, by providing configuration parameters. 88 | 89 | | Param | Type | Description | 90 | | --- | --- | --- | 91 | | settings | `object` | Object with settings for initialization. | 92 | | settings.redisUrl | `string` | URL of a [Redis] server. | 93 | | settings.websocketUrl | `string` | Publicly or inter-network accessible URL to this servers WebSocket endpoint. | 94 | | settings.scriptsPath | `string` | Relative path to script components. | 95 | | settings.templatesPath | `string` | Relative path to templates. | 96 | | settings.levelProvider | `object` | Instance of a level provider. | 97 | | settings.server | `http.Server` | `https.Server` | Instance of a http(s) server. | 98 | 99 | 100 | 101 | # Callbacks 102 | 103 | 104 | ### responseCallback [callback] => (error, data) 105 | 106 | | Param | Type | Description | 107 | | --- | --- | --- | 108 | | error | ````null```` | ```[Error]``` | Error provided with a response. | 109 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 110 | 111 | 112 | 113 | 114 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 115 | [Redis]: https://redis.io/ 116 | [Error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error 117 | [PlayNetwork]: ./PlayNetwork.md 118 | [User]: ./User.md 119 | [Users]: ./Users.md 120 | [Rooms]: ./Rooms.md 121 | [NetworkEntity]: ./NetworkEntity.md 122 | [Map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map 123 | -------------------------------------------------------------------------------- /src/client/room.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class Room 3 | * @classdesc Room to which {@link User} has joined. 4 | * @extends pc.EventHandler 5 | * @property {number} id Numerical ID. 6 | * @property {number} tickrate Server tickrate of this {@link Room}. 7 | * @property {pc.Entity} root Root {@link pc.Entity} of this {@link Room}. 8 | * @property {number} latency Latency of this {@link Room} that takes in 9 | * account network latency and server application update frequency. 10 | */ 11 | 12 | /** 13 | * @event Room#join 14 | * @description Fired when {@link User} has joined a {@link Room}. 15 | * @param {User} user {@link User} that is associated with this {@link Room}. 16 | */ 17 | 18 | /** 19 | * @event Room#leave 20 | * @description Fired when {@link User} has left a {@link Room}. 21 | * @param {User} user {@link User} that was associated with this {@link Room}. 22 | */ 23 | 24 | /** 25 | * @event Room#destroy 26 | * @description Fired when {@link Room} has been destroyed. 27 | */ 28 | 29 | /** 30 | * @event Room#* 31 | * @description Fired when a {@link Room} received a named network message. 32 | * @param {null|object|array|string|number|boolean} [data] Message data. 33 | */ 34 | 35 | class Room extends pc.EventHandler { 36 | constructor(id, tickrate, users) { 37 | super(); 38 | 39 | this.id = id; 40 | this.tickrate = tickrate; 41 | this.users = new Map(); 42 | this.networkEntities = new NetworkEntities(); 43 | 44 | this._hierarchyHandler = pc.app.loader.getHandler('hierarchy'); 45 | 46 | this.root = null; 47 | this.latency = 0; 48 | 49 | for (const key in users) { 50 | const userData = users[key]; 51 | const user = new User(userData.id); 52 | this.users.set(user.id, user); 53 | } 54 | 55 | this.on('_user:join', this._onUserJoin, this); 56 | this.on('_user:leave', this._onUserLeave, this); 57 | 58 | this.on('_networkEntities:add', this._onNetworkEntityAdd, this); 59 | this.on('_networkEntities:create', this._onNetworkEntityCreate, this); 60 | this.on('_networkEntities:delete', this._onNetworkEntityDelete, this); 61 | this.on('_state:update', this._onUpdate, this); 62 | } 63 | 64 | /** 65 | * @method send 66 | * @description Send named message to a Room. 67 | * @param {string} name Name of a message. 68 | * @param {null|object|array|string|number|boolean} [data] JSON friendly message data. 69 | * @param {responseCallback} [callback] Callback that will be fired when response message is received. 70 | */ 71 | send(name, data, callback) { 72 | pn._send(name, data, 'room', this.id, callback); 73 | } 74 | 75 | _onUserJoin(userData) { 76 | const user = userData.id === pn.me.id ? pn.me : new User(userData.id); 77 | this.users.set(user.id, user); 78 | 79 | if (user.mine) pn.fire('join', this); 80 | this.fire('join', user); 81 | } 82 | 83 | _onUserLeave(id) { 84 | const user = this.users.get(id); 85 | this.users.delete(user.id) 86 | this.fire('leave', user); 87 | user.destroy(); 88 | } 89 | 90 | _onNetworkEntityAdd(networkEntity) { 91 | this.networkEntities.add(networkEntity); 92 | } 93 | 94 | _onNetworkEntityCreate(data) { 95 | const parentIndex = new Map(); 96 | for (const id in data.entities) { 97 | const parentId = data.entities[id].parent; 98 | if (!parentId || data.entities[parentId]) continue; 99 | parentIndex.set(parentId, id); 100 | data.entities[id].parent = null; 101 | } 102 | 103 | const entity = this._hierarchyHandler.open('', data); 104 | const wasEnabled = entity.enabled; 105 | entity.enabled = false; 106 | 107 | for (const [parentId, id] of parentIndex) { 108 | const parent = pc.app.root.findByGuid(parentId); 109 | const child = entity.getGuid() === id ? entity : entity.findByGuid(id); 110 | 111 | if (!parent) { 112 | console.log(`entity ${child.name} unknown parent ${parentId}`); 113 | continue; 114 | } 115 | 116 | parent.addChild(child); 117 | } 118 | 119 | entity.enabled = wasEnabled; 120 | 121 | entity.forEach((entity) => { 122 | const networkEntity = entity?.script?.networkEntity; 123 | if (!networkEntity) return; 124 | 125 | this.networkEntities.add(networkEntity); 126 | }); 127 | } 128 | 129 | _onNetworkEntityDelete(id) { 130 | const networkEntity = this.networkEntities.get(id); 131 | if (!networkEntity) return; 132 | 133 | networkEntity.entity.destroy(); 134 | } 135 | 136 | _onUpdate(data) { 137 | for (let i = 0; i < data.length; i++) { 138 | const id = data[i].id; 139 | const networkEntity = this.networkEntities.get(id); 140 | if (!networkEntity) continue; 141 | networkEntity.setState(data[i]); 142 | } 143 | } 144 | 145 | destroy() { 146 | this.networkEntities = null; 147 | this.users = null; 148 | this.root.destroy(); 149 | 150 | pn.fire('leave', this); 151 | this.fire('destroy'); 152 | this.off(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/client/interpolation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class InterpolateValue 3 | * @classdesc Helper class to interpolate values between states. It has mechanics 4 | * to smoothen unreliable intervals of state and can interpolate simple values 5 | * such as `number`, as well as complex: {@link pc.Vec2}, {@link pc.Vec3}, {@link pc.Vec4}, {@link pc.Quat}, {@link pc.Color}. 6 | * @property {number|pc.Vec2|pc.Vec3|pc.Vec4|pc.Quat|pc.Color} value Current Value, 7 | * that it interpolated between states on every update. 8 | */ 9 | 10 | class InterpolateValue { 11 | INTERPOLATION_STATES_LIMIT = 8; 12 | 13 | /** 14 | * @constructor 15 | * @param {number|pc.Vec2|pc.Vec3|pc.Vec4|pc.Quat|pc.Color} value Value to interpolate. 16 | * Can be a simple `number`, as well as complex: {@link pc.Vec2}, {@link pc.Vec3}, {@link pc.Vec4}, {@link pc.Quat}, {@link pc.Color} object with `lerp` or `slerp`, `copy` and `clone` method. 17 | */ 18 | constructor(value, object, key, setter, tickrate) { 19 | // TODO 20 | // challenge object,key,setter, as it becomes too large of a constructor 21 | // maybe `attach` method can simplify it 22 | this.type = typeof (value) === 'object' ? value.constructor : null; 23 | this.pool = []; 24 | this.states = []; 25 | this.current = 0; 26 | this.time = 0; 27 | this.speed = 1; 28 | this.tickrate = tickrate; 29 | 30 | this.from = this.type ? value.clone() : value; 31 | this.value = this.type ? value.clone() : value; 32 | 33 | this.object = object; 34 | this.key = key; 35 | this.setter = setter; 36 | } 37 | 38 | /** 39 | * @method set 40 | * @description Force a value set, ignoring an interpolation. 41 | * @param {number|pc.Vec2|pc.Vec3|pc.Vec4|pc.Quat|pc.Color} value 42 | */ 43 | set(value) { 44 | if (this.type) { 45 | this.from.copy(value); 46 | this.value.copy(value); 47 | } else { 48 | this.from = value; 49 | this.value = value; 50 | } 51 | } 52 | 53 | /** 54 | * @method add 55 | * @description Add a value to list of interpolation states. 56 | * @param {number|pc.Vec2|pc.Vec3|pc.Vec4|pc.Quat|pc.Color} value 57 | */ 58 | add(value) { 59 | if (this.type) { 60 | let vec; 61 | 62 | if (this.states.length > this.INTERPOLATION_STATES_LIMIT) { 63 | vec = this.states.shift(); 64 | } else if (this.pool.length) { 65 | vec = this.pool.pop(); 66 | } else { 67 | vec = new this.type(); 68 | } 69 | 70 | vec.copy(value); 71 | this.states.push(vec); 72 | } else { 73 | this.states.push(value); 74 | } 75 | } 76 | 77 | /** 78 | * @method update 79 | * @description Call an update, with should be called at the application 80 | * update interval. This will progress interpolation through states based on Delta Time. 81 | * @param {number} dt Delta Time of an application update frequency. 82 | */ 83 | update(dt) { 84 | if (!this.states.length) 85 | return; 86 | 87 | const duration = 1.0 / this.tickrate; 88 | let to, lerp; 89 | 90 | // TODO 91 | // interpolator should always work before the last state 92 | // to ensure there is extra state available, 93 | // so it should not run out of states while they come regularly 94 | 95 | let speed = 1; 96 | if (this.states.length <= 2) { 97 | speed = 0.9; 98 | } else { 99 | speed = 1 + Math.max(0, Math.min(10, this.states.length - 2)) * 0.01; 100 | } 101 | this.speed += (speed - this.speed) * 0.1; 102 | 103 | this.time += dt * this.speed; 104 | if (this.time >= duration) { 105 | this.time -= duration; 106 | this.current++; 107 | 108 | if (this.type) { 109 | this.from.copy(this.value); 110 | } else { 111 | this.from = this.value; 112 | } 113 | 114 | while (this.current > 0) { 115 | let state = this.states.shift(); 116 | if (this.type) 117 | this.pool.push(state); 118 | to = state; 119 | this.current--; 120 | } 121 | } 122 | 123 | if (!this.states.length) { 124 | lerp = 1; 125 | } else { 126 | to = this.states[this.current]; 127 | lerp = Math.min(1.0, this.time / duration); 128 | } 129 | 130 | if (this.type) { 131 | if (lerp === 1) { 132 | this.value.copy(to); 133 | } else { 134 | if (this.value.slerp) { 135 | this.value.slerp(this.from, to, lerp); 136 | } else { 137 | this.value.lerp(this.from, to, lerp); 138 | } 139 | } 140 | } else { 141 | this.value = (this.from * lerp) + (this.value * (1 - lerp)); 142 | } 143 | 144 | if (this.setter) { 145 | this.setter(this.value); 146 | } else if (this.object) { 147 | if (this.type) { 148 | this.object[this.key] = this.object[this.key].copy(this.value); 149 | } else { 150 | this.object[this.key] = this.value; 151 | } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/server/core/user.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | import pn from './../index.js'; 3 | 4 | import performance from '../libs/performance.js'; 5 | 6 | /** 7 | * @class User 8 | * @classdesc User interface which is created for each individual connection and inter-connections to a {@link PlayNetwork}. 9 | * @extends pc.EventHandler 10 | * @property {number|string} id Unique identifier for the user. 11 | * @property {null|Room} room {@link Room} that {@link User} is currently joined to. 12 | * @property {number} latency Latency of the connection in milliseconds. 13 | */ 14 | 15 | /** 16 | * @event User#join 17 | * @description Fired when {@link User} is joined to a {@link Room}. 18 | * @param {Room} room {@link Room} that {@link User} is joined. 19 | */ 20 | 21 | /** 22 | * @event User#leave 23 | * @description Fired when {@link User} left {@link Room}. 24 | * @param {Room} room {@link Room} that {@link User} left. 25 | */ 26 | 27 | /** 28 | * @event User#destroy 29 | * @description Fired after disconnect and related data is destroyed. 30 | */ 31 | 32 | /** 33 | * @event User#* 34 | * @description {@link User} will receive own named network messages. 35 | * @param {User} sender {@link User} that sent the message. 36 | * @param {object|array|string|number|boolean} [data] Message data. 37 | * @param {responseCallback} callback Callback that can be called to respond to a message. 38 | */ 39 | 40 | export default class User extends pc.EventHandler { 41 | constructor(id, socket, serverId) { 42 | super(); 43 | 44 | this.id = id; 45 | this.serverId = serverId; 46 | this.room = null; 47 | 48 | if (serverId) return; 49 | 50 | this.socket = socket; 51 | performance.addLatency(this); 52 | 53 | this.on('_send', (_, msg) => { 54 | this._send(msg.name, msg.data, msg.scope?.type, msg.scope?.id, msg.id); 55 | }, this); 56 | } 57 | 58 | /** 59 | * @method join 60 | * @description Join to a {@link Room}. 61 | * @async 62 | * @param {number} roomId ID of the {@link Room} to join. 63 | * @returns {null|Error} returns {@link Error} if failed to join. 64 | */ 65 | async join(roomId) { 66 | const room = pn.rooms.get(roomId); 67 | if (!room) { 68 | const serverId = parseInt(await pn.redis.HGET('_route:room', roomId.toString())); 69 | if (!serverId) return new Error('Room not found'); 70 | this.room = roomId; 71 | pn.servers.get(serverId, (server) => { 72 | server.send('_room:join', roomId, null, null, this.id); 73 | }); 74 | return null; 75 | }; 76 | 77 | if (this.room) { 78 | if (this.room.id === roomId) return new Error('Already in this room'); 79 | await this.leave(); 80 | } 81 | 82 | const usersData = {}; 83 | for (const [id, user] of room.users) { 84 | usersData[id] = user.toData(); 85 | } 86 | 87 | this.room = room; 88 | 89 | this.send('_room:join', { 90 | tickrate: room.tickrate, 91 | users: usersData, 92 | level: room.toData(), 93 | state: room.networkEntities.getState(true), 94 | id: room.id 95 | }); 96 | this.room.users.set(this.id, this); 97 | this.room.send('_user:join', this.toData()); 98 | 99 | this.room.fire('join', this); 100 | this.fire('join', this.room); 101 | 102 | pn.rooms.fire('join', this.room, this); 103 | 104 | return null; 105 | } 106 | 107 | /** 108 | * @method leave 109 | * @description Leave a {@link Room} to which is currently joined. 110 | * @async 111 | * @returns {null|Error} returns {@link Error} if failed to leave. 112 | */ 113 | async leave() { 114 | if (!this.room) return new Error('Not in a room'); 115 | if (isFinite(this.room)) { 116 | const serverId = parseInt(await pn.redis.HGET('_route:room', this.room.toString())); 117 | if (!serverId) return new Error('Room not found'); 118 | pn.servers.get(serverId, (server) => { 119 | server.send('_room:leave', null, null, null, this.id); 120 | }); 121 | this.room = null; 122 | return null; 123 | } 124 | 125 | this.send('_room:leave'); 126 | this.room.users.delete(this.id); 127 | this.room.send('_user:leave', this.id); 128 | 129 | this.room.fire('leave', this); 130 | this.fire('leave', this.room); 131 | 132 | pn.rooms.fire('leave', this.room, this); 133 | 134 | this.room = null; 135 | 136 | return null; 137 | } 138 | 139 | /** 140 | * @method send 141 | * @description Send a named message to a {@link User}. 142 | * @param {string} name Name of a message. 143 | * @param {object|array|string|number|boolean} [data] JSON friendly message data. 144 | */ 145 | send(name, data) { 146 | this._send(name, data, 'user', this.id); 147 | } 148 | 149 | _send(name, data, scope, id, msgId) { 150 | const msg = { name, data, scope: { type: scope, id }, id: msgId }; 151 | 152 | if (!this.serverId) return this.socket.send(JSON.stringify(msg)); 153 | pn.servers.get(this.serverId, (server) => server.send('_send', msg, 'user', this.id, this.id)); 154 | } 155 | 156 | toData() { 157 | return { 158 | id: this.id 159 | }; 160 | } 161 | 162 | async destroy() { 163 | this.leave(); 164 | this.room = null; 165 | 166 | if (!this.serverId) { 167 | performance.removeLatency(this); 168 | await pn.redis.HDEL('_route:user', this.id.toString()); 169 | pn.redis.PUBLISH('_destroy:user', this.id.toString()); 170 | } 171 | 172 | this.fire('destroy'); 173 | this.off(); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /tests/rooms.js: -------------------------------------------------------------------------------- 1 | import { jest, describe, test, expect, beforeAll } from '@jest/globals'; 2 | 3 | import pn from './../src/server/index.js'; 4 | import serverLevels from './../src/server/libs/levels.js'; 5 | 6 | import { testJson, testLevel } from './utils/constants.js'; 7 | 8 | export const levels = () => { 9 | beforeAll(() => { 10 | global.client.levels._getEditorSceneData = jest.fn((sceneId, callback) => { 11 | callback(testLevel); 12 | }); 13 | }); 14 | 15 | describe('Levels', () => { 16 | test('Level saved', (done) => { 17 | global.client.levels.save(testLevel.scene, (err) => { 18 | try { 19 | expect(err).toBeNull(); 20 | expect(serverLevels.provider.has(testLevel.scene)).toBeTruthy(); 21 | } finally { 22 | done(); 23 | } 24 | }); 25 | }); 26 | }); 27 | }; 28 | 29 | export const rooms = () => { 30 | describe('Rooms', () => { 31 | test('Client tried create room without data', (done) => { 32 | global.client.createRoom(null, (err) => { 33 | try { 34 | expect(err).toBeTruthy(); 35 | expect(err.message).toBe('No levelId provided'); 36 | } finally { 37 | done(); 38 | } 39 | }); 40 | }); 41 | 42 | test('Client tried create room with invalid levelId', (done) => { 43 | global.client.createRoom({ levelId: 'invalid' }, (err) => { 44 | try { 45 | expect(err).toBeTruthy(); 46 | expect(err.message).toBe('Level not found'); 47 | } finally { 48 | done(); 49 | } 50 | }); 51 | }); 52 | 53 | test('Client tried joining room without id', (done) => { 54 | global.client.joinRoom(null, (err) => { 55 | try { 56 | expect(err).toBeTruthy(); 57 | expect(err.message).toBe('Room id is required'); 58 | } finally { 59 | done(); 60 | } 61 | }); 62 | }); 63 | 64 | test('Client tried joining room with invalid id', (done) => { 65 | global.client.joinRoom('invalid', (err) => { 66 | try { 67 | expect(err).toBeTruthy(); 68 | expect(err.message).toBe('Room not found'); 69 | } finally { 70 | done(); 71 | } 72 | }); 73 | }); 74 | 75 | let createdRoomId = null; 76 | 77 | test('Client created room', (done) => { 78 | global.client.createRoom({ levelId: testLevel.scene }, (err, roomId) => { 79 | try { 80 | createdRoomId = roomId; 81 | expect(err).toBeNull(); 82 | expect(roomId).toBeTruthy(); 83 | expect(pn.rooms.has(roomId)); 84 | } finally { 85 | done(); 86 | } 87 | }); 88 | }); 89 | 90 | let createdRoom = null; 91 | 92 | test('Client joined room', (done) => { 93 | pn.rooms.once('join', (room, user) => { 94 | createdRoom = room; 95 | 96 | expect(room).toBeTruthy(); 97 | expect(user).toBeTruthy(); 98 | expect(room.id).toBe(createdRoomId); 99 | expect(user.id).toBe(global.client.me.id); 100 | expect(room.users.has(global.client.me.id)).toBeTruthy(); 101 | expect(user.room === room).toBeTruthy(); 102 | }); 103 | 104 | global.client.once('join', (room) => { 105 | try { 106 | expect(room).toBeTruthy(); 107 | expect(global.client.room).toBeTruthy(); 108 | expect(room.id).toBe(createdRoomId); 109 | expect(room.users.has(global.client.me.id)).toBeTruthy(); 110 | } finally { 111 | done(); 112 | } 113 | }); 114 | 115 | global.client.joinRoom(createdRoomId, (err) => { 116 | expect(err).toBeNull(); 117 | }); 118 | }); 119 | 120 | test('Client sent message to room', (done) => { 121 | createdRoom.once('test', (from, data, callback) => { 122 | try { 123 | expect(from.id).toBe(global.serverUser.id); 124 | expect(data).toEqual(testJson); 125 | } finally { 126 | callback(null, data); 127 | } 128 | }); 129 | 130 | global.client.room.send('test', testJson, (err, data) => { 131 | try { 132 | expect(err).toBeNull(); 133 | expect(data).toEqual(testJson); 134 | } finally { 135 | done(); 136 | } 137 | }); 138 | }); 139 | 140 | test('Client left room', (done) => { 141 | global.client.once('leave', (room) => { 142 | try { 143 | expect(room).toBeTruthy(); 144 | expect(room.id).toBe(createdRoomId); 145 | expect(global.client.room).toBeNull(); 146 | } finally { 147 | done(); 148 | } 149 | }); 150 | 151 | global.client.leaveRoom((err) => { 152 | expect(err).toBeNull(); 153 | }); 154 | }); 155 | 156 | test('Client left unexisting room', (done) => { 157 | global.client.leaveRoom((err) => { 158 | try { 159 | expect(err).toBeTruthy(); 160 | expect(err.message).toBe('Not in a Room'); 161 | } finally { 162 | done(); 163 | } 164 | }); 165 | }); 166 | }); 167 | }; 168 | -------------------------------------------------------------------------------- /src/server/core/room.js: -------------------------------------------------------------------------------- 1 | import * as pc from 'playcanvas'; 2 | import pn from './../index.js'; 3 | import { HTMLCanvasElement } from '@playcanvas/canvas-mock/src/index.mjs'; 4 | 5 | import NetworkEntities from './network-entities/network-entities.js'; 6 | 7 | import scripts from './../libs/scripts.js'; 8 | import templates from './../libs/templates.js'; 9 | import levels from './../libs/levels.js'; 10 | import performance from '../libs/performance.js'; 11 | 12 | /** 13 | * @class Room 14 | * @classdesc A Room represents own {@link pc.Application} context, with a list of joined {@link User}s. 15 | * @extends pc.EventHandler 16 | * @property {number} id Unique ID of a {@link Room}. 17 | * @property {pc.Application} app PlayCanvas {@link pc.Application} associated with a {@link Room}. 18 | * @property {Map} users Map of joined {@link User}s to a room. Indexed by a user ID. 19 | */ 20 | 21 | /** 22 | * @event Room#initialize 23 | * @description Fired when {@link Room} has been loaded, initialized, and {@link pc.Application} started. 24 | */ 25 | 26 | /** 27 | * @event Room#join 28 | * @description Fired when {@link User} has joined a {@link Room}. 29 | * @param {User} user 30 | */ 31 | 32 | /** 33 | * @event Room#leave 34 | * @description Fired when {@link User} has left a {@link Room}. 35 | * @param {User} user 36 | */ 37 | 38 | /** 39 | * @event Room#error 40 | * @description Fired when {@link pc.Application} throws an error. This is a 41 | * good place to handle gameplay errors. 42 | * @param {Error} error {@link Error} object. 43 | */ 44 | 45 | /** 46 | * @event Room#destroy 47 | * @description Fired when {@link Room} has been destroyed. 48 | */ 49 | 50 | /** 51 | * @event Room#* 52 | * @description {@link Room} will receive own named network messages. 53 | * @param {User} sender {@link User} that sent the message. 54 | * @param {object|array|string|number|boolean} [data] Message data. 55 | * @param {responseCallback} callback Callback that can be called to respond to a message. 56 | */ 57 | 58 | export default class Room extends pc.EventHandler { 59 | constructor(id, tickrate = 20) { 60 | super(); 61 | 62 | this.id = id; 63 | 64 | this.app = this._createApplication(); 65 | this.app.room = this; 66 | this.root = null; 67 | 68 | this.level = null; 69 | this.users = new Map(); 70 | this.networkEntities = new NetworkEntities(this.app); 71 | 72 | this.timeout = null; 73 | this.tick = 0; 74 | this.tickrate = tickrate; 75 | this.lastTickTime = Date.now(); 76 | this.currentTickTime = Date.now(); 77 | this.dt = (this.currentTickTime - this.lastTickTime) / 1000; 78 | performance.addRoomLatency(this); 79 | } 80 | 81 | async initialize(levelId) { 82 | await templates.addApplication(this.app); 83 | 84 | await this._loadLevel(levelId); 85 | 86 | this.app.start(); 87 | 88 | this.timeout = setInterval(() => { 89 | this._update(); 90 | }, 1000 / this.tickrate); 91 | 92 | this.app.start(); 93 | 94 | this.fire('initialize'); 95 | } 96 | 97 | /** 98 | * @method send 99 | * @description Send named message to every {@link User} in this Room. 100 | * @param {string} name Name of a message. 101 | * @param {object|array|string|number|boolean} [data] JSON friendly message data. 102 | */ 103 | send(name, data) { 104 | for (const user of this.users.values()) { 105 | user._send(name, data, 'room', this.id); 106 | } 107 | } 108 | 109 | toData() { 110 | const data = { 111 | scene: this.level.scene.toString(), 112 | name: this.level.name, 113 | item_id: Math.random().toString(), 114 | settings: this.level.settings, 115 | entities: this.networkEntities.toData(this.root) 116 | }; 117 | 118 | data.entities[this.root.getGuid()].parent = null; 119 | 120 | return data; 121 | } 122 | 123 | destroy() { 124 | if (!this.app) return; 125 | 126 | clearTimeout(this.timeout); 127 | this.timeout = null; 128 | 129 | this.app.destroy(); 130 | // TODO: remove when playcanvas application will be destroyed properly 131 | // https://github.com/playcanvas/engine/issues/4135 132 | this.app.room = null; 133 | this.app = null; 134 | 135 | this.level = null; 136 | this.networkEntities = null; 137 | performance.removeRoomLatency(this); 138 | 139 | pn.redis.HDEL('_route:room', this.id.toString()); 140 | 141 | this.fire('destroy'); 142 | this.off(); 143 | } 144 | 145 | _createApplication() { 146 | const canvas = new HTMLCanvasElement(100, 100); 147 | canvas.id = this.id; 148 | 149 | const app = new pc.Application(canvas); 150 | // disable render 151 | app.autoRender = false; 152 | // trigger libraries loaded 153 | app.onLibrariesLoaded(); 154 | // update script registry to a global one 155 | app.scripts = scripts.registry; 156 | 157 | return app; 158 | } 159 | 160 | async _loadLevel(levelId) { 161 | this.level = await levels.load(levelId); 162 | 163 | const item = new pc.SceneRegistryItem(this.level.name, this.level.scene.toString()); 164 | 165 | item.data = this.level; 166 | item._loading = false; 167 | 168 | return new Promise((resolve) => { 169 | this.app.scenes.loadSceneSettings(item, () => { 170 | this.app.scenes.loadSceneHierarchy(item, () => { 171 | this.root = this.app.root.children[0]; 172 | resolve(); 173 | }); 174 | }); 175 | }); 176 | } 177 | 178 | _update() { 179 | if (!this.app) return; 180 | 181 | this.currentTickTime = Date.now(); 182 | this.dt = (this.currentTickTime - this.lastTickTime) / 1000; 183 | this.lastTickTime = this.currentTickTime; 184 | this.tick++; 185 | 186 | try { 187 | this.app.update(this.dt); 188 | const state = this.networkEntities.getState(); 189 | 190 | if (state.length) { 191 | this.send('_state:update', state); 192 | } 193 | 194 | performance.handlePings(this); 195 | } catch (ex) { 196 | console.error(ex); 197 | this.fire('error', ex); 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /docs/client/PlayNetwork.md: -------------------------------------------------------------------------------- 1 | # PlayNetwork (client) 2 | extends [pc.EventHandler] 3 | 4 | Main interface to connect to a server and interact with networked data. 5 | 6 | --- 7 | 8 | # Index 9 | 10 | ### Properties 11 | 12 | .me : [User] 13 | .room : [Room] 14 | .latency : `number` 15 | .levels : [Levels] 16 | 17 | ### Events 18 | 19 | connect => (user) 20 | disconnect 21 | error => (error) 22 | * => ([data]) 23 | 24 | ### Functions 25 | 26 | connect(host, port, useSSL, payload, callback) 27 | createRoom(data, callback) 28 | joinRoom(id, callback) 29 | leaveRoom(callback) 30 | send(name, [data], [callback]) 31 | 32 | 33 | --- 34 | 35 | 36 | # Properties 37 | 38 | 39 | ### .me : [User] 40 | Local [User] object. 41 | 42 | 43 | ### .room : [Room] 44 | [Room] that [User] has joined. 45 | 46 | 47 | ### .latency : `number` 48 | Current network latency in miliseconds. 49 | 50 | 51 | ### .levels : [Levels] 52 | Interface that allows to save hierarchy data to a server. 53 | 54 | 55 | 56 | # Events 57 | 58 | 59 | ### connect [event] => (user) 60 | Fired when client has connected to a server and received own [User] data. 61 | 62 | | Param | Type | Description | 63 | | --- | --- | --- | 64 | | user | [User] | Own [User] instance. | 65 | 66 | 67 | 68 | ### disconnect [event] 69 | Fired after client has been disconnected from a server. 70 | 71 | 72 | 73 | 74 | ### error [event] => (error) 75 | Fired when networking error occurs. 76 | 77 | | Param | Type | 78 | | --- | --- | 79 | | error | [Error] | 80 | 81 | 82 | 83 | ### * [event] => ([data]) 84 | Fired on receive of a named network messages. 85 | 86 | | Param | Type | Description | 87 | | --- | --- | --- | 88 | | data | `null` | `object` | `array` | `string` | `number` | `boolean` | Message data. | 89 | 90 | 91 | # Functions 92 | 93 | 94 | ### connect(host, port, useSSL, payload, callback) 95 | 96 | Create a WebSocket connection to the PlayNetwork server. 97 | 98 | | Param | Type | Description | 99 | | --- | --- | --- | 100 | | host | `string` | Host of a server. | 101 | | port | `number` | Port of a server. | 102 | | useSSL | `boolean` | Use secure connection. | 103 | | payload | `object` | `array` | `string` | `number` | `boolean` | `null` | Client authentication data. | 104 | | callback | connectCallback | Will be fired when connection is succesfull or failed. | 105 | 106 | 107 | 108 | ### createRoom(data, callback) 109 | 110 | Send a request to a server, to create a [Room]. 111 | 112 | | Param | Type | Description | 113 | | --- | --- | --- | 114 | | data | `object` | Request data that can be used by Server to decide room creation. | 115 | | callback | createRoomCallback | Will be fired when room is created or failed. | 116 | 117 | 118 | 119 | ### joinRoom(id, callback) 120 | 121 | Send a request to a server, to join a [Room]. 122 | 123 | | Param | Type | Description | 124 | | --- | --- | --- | 125 | | id | `number` | ID of a [Room] to join. | 126 | | callback | joinRoomCallback | Will be fired when [Room] is joined or failed. | 127 | 128 | 129 | 130 | ### leaveRoom(callback) 131 | 132 | Send a request to a server, to leave current [Room]. 133 | 134 | | Param | Type | Description | 135 | | --- | --- | --- | 136 | | callback | leaveRoomCallback | Will be fired when [Room] is left or failed. | 137 | 138 | 139 | 140 | ### send(name, [data], [callback]) 141 | 142 | Send named message to the server. 143 | 144 | | Param | Type | Description | 145 | | --- | --- | --- | 146 | | name | `string` | Name of a message. | 147 | | data (optional) | `object` | `array` | `string` | `number` | `boolean` | JSON friendly message data. | 148 | | callback (optional) | responseCallback | Callback that will be fired when a response message is received. | 149 | 150 | 151 | 152 | # Callbacks 153 | 154 | 155 | ### connectCallback [callback] => (error, user) 156 | 157 | | Param | Type | Description | 158 | | --- | --- | --- | 159 | | error | `null` | [Error] | Error if connection failed. | 160 | | user | `null` | [User] | Own [User] object. | 161 | 162 | 163 | 164 | 165 | 166 | ### createRoomCallback [callback] => (error, roomId) 167 | 168 | | Param | Type | Description | 169 | | --- | --- | --- | 170 | | error | `null` | [Error] | Error if failed to create a [Room]. | 171 | | roomId | `null` | `number` | ID of a created [Room]. | 172 | 173 | 174 | 175 | 176 | 177 | ### joinRoomCallback [callback] => (error) 178 | 179 | | Param | Type | Description | 180 | | --- | --- | --- | 181 | | error | `null` | [Error] | Error if failed to join a [Room]. | 182 | 183 | 184 | 185 | 186 | 187 | ### leaveRoomCallback [callback] => (error) 188 | 189 | | Param | Type | Description | 190 | | --- | --- | --- | 191 | | error | `null` | [Error] | Error if failed to leave a [Room]. | 192 | 193 | 194 | 195 | 196 | 197 | ### responseCallback [callback] => (error, data) 198 | 199 | | Param | Type | Description | 200 | | --- | --- | --- | 201 | | error | ````null```` | ```[Error]``` | Error provided with with a response. | 202 | | data | ````null```` | ````object```` | ````array```` | ````string```` | ````number```` | ````boolean```` | Data provided with a response. | 203 | 204 | 205 | 206 | 207 | [pc.EventHandler]: https://developer.playcanvas.com/en/api/pc.EventHandler.html 208 | [Room]: ./Room.md 209 | [User]: ./User.md 210 | [Error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error 211 | [Levels]: ./Levels.md 212 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // Automatically clear mock calls, instances, contexts and results before every test 14 | // clearMocks: false, 15 | 16 | // Indicates whether the coverage information should be collected while executing the test 17 | // collectCoverage: false, 18 | 19 | // An array of glob patterns indicating a set of files for which coverage information should be collected 20 | // collectCoverageFrom: undefined, 21 | 22 | // The directory where Jest should output its coverage files 23 | // coverageDirectory: undefined, 24 | 25 | // An array of regexp pattern strings used to skip coverage collection 26 | // coveragePathIgnorePatterns: [ 27 | // "\\\\node_modules\\\\" 28 | // ], 29 | 30 | // Indicates which provider should be used to instrument code for coverage 31 | // coverageProvider: 'v8', 32 | 33 | // A list of reporter names that Jest uses when writing coverage reports 34 | // coverageReporters: [ 35 | // "json", 36 | // "text", 37 | // "lcov", 38 | // "clover" 39 | // ], 40 | 41 | // An object that configures minimum threshold enforcement for coverage results 42 | // coverageThreshold: undefined, 43 | 44 | // A path to a custom dependency extractor 45 | // dependencyExtractor: undefined, 46 | 47 | // Make calling deprecated APIs throw helpful error messages 48 | // errorOnDeprecated: false, 49 | 50 | // The default configuration for fake timers 51 | // fakeTimers: { 52 | // "enableGlobally": false 53 | // }, 54 | 55 | // Force coverage collection from ignored files using an array of glob patterns 56 | // forceCoverageMatch: [], 57 | 58 | // A path to a module which exports an async function that is triggered once before all test suites 59 | // globalSetup: undefined, 60 | 61 | // A path to a module which exports an async function that is triggered once after all test suites 62 | // globalTeardown: undefined, 63 | 64 | // A set of global variables that need to be available in all test environments 65 | // globals: {}, 66 | 67 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 68 | // maxWorkers: "50%", 69 | 70 | // An array of directory names to be searched recursively up from the requiring module's location 71 | // moduleDirectories: [ 72 | // "node_modules" 73 | // ], 74 | 75 | // An array of file extensions your modules use 76 | // moduleFileExtensions: [ 77 | // "js", 78 | // "mjs", 79 | // "cjs", 80 | // "jsx", 81 | // "ts", 82 | // "tsx", 83 | // "json", 84 | // "node" 85 | // ], 86 | 87 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 88 | // moduleNameMapper: {}, 89 | 90 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 91 | // modulePathIgnorePatterns: [], 92 | 93 | // Activates notifications for test results 94 | // notify: false, 95 | 96 | // An enum that specifies notification mode. Requires { notify: true } 97 | // notifyMode: "failure-change", 98 | 99 | // A preset that is used as a base for Jest's configuration 100 | // preset: undefined, 101 | 102 | // Run tests from one or more projects 103 | // projects: undefined, 104 | 105 | // Use this configuration option to add custom reporters to Jest 106 | // reporters: undefined, 107 | 108 | // Automatically reset mock state before every test 109 | // resetMocks: false, 110 | 111 | // Reset the module registry before running each individual test 112 | // resetModules: false, 113 | 114 | // A path to a custom resolver 115 | // resolver: undefined, 116 | 117 | // Automatically restore mock state and implementation before every test 118 | // restoreMocks: false, 119 | 120 | // The root directory that Jest should scan for tests and modules within 121 | // rootDir: undefined, 122 | 123 | // A list of paths to directories that Jest should use to search for files in 124 | // roots: [ 125 | // "" 126 | // ], 127 | 128 | // Allows you to use a custom runner instead of Jest's default test runner 129 | // runner: "jest-runner", 130 | 131 | // The paths to modules that run some code to configure or set up the testing environment before each test 132 | setupFiles: ['/tests/setup.js'], 133 | 134 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 135 | // setupFilesAfterEnv: [], 136 | 137 | // The number of seconds after which a test is considered as slow and reported as such in the results. 138 | // slowTestThreshold: 5, 139 | 140 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 141 | // snapshotSerializers: [], 142 | 143 | // The test environment that will be used for testing 144 | testEnvironment: 'jest-environment-jsdom' 145 | 146 | // Options that will be passed to the testEnvironment 147 | // testEnvironmentOptions: {}, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | // testMatch: [ 154 | // "**/__tests__/**/*.[jt]s?(x)", 155 | // "**/?(*.)+(spec|test).[tj]s?(x)" 156 | // ], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | // testPathIgnorePatterns: [ 160 | // "\\\\node_modules\\\\" 161 | // ], 162 | 163 | // The regexp pattern or array of patterns that Jest uses to detect test files 164 | // testRegex: [], 165 | 166 | // This option allows the use of a custom results processor 167 | // testResultsProcessor: undefined, 168 | 169 | // This option allows use of a custom test runner 170 | // testRunner: "jest-circus/runner", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "\\\\node_modules\\\\", 178 | // "\\.pnp\\.[^\\\\]+$" 179 | // ], 180 | 181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 182 | // unmockedModulePathPatterns: undefined, 183 | 184 | // Indicates whether each individual test should be reported during the run 185 | // verbose: undefined, 186 | 187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 188 | // watchPathIgnorePatterns: [], 189 | 190 | // Whether to use watchman for file crawling 191 | // watchman: true, 192 | }; 193 | -------------------------------------------------------------------------------- /src/server/libs/templates.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import chokidar from 'chokidar'; 5 | 6 | import scripts from './scripts.js'; 7 | 8 | import { unifyPath } from './utils.js'; 9 | 10 | class Templates { 11 | apps = new Map(); 12 | 13 | assetIdByPath = new Map(); 14 | 15 | logging = false; 16 | 17 | async initialize(directory) { 18 | this.directory = directory; 19 | 20 | pc.ComponentSystem.prototype.addComponent = function addComponent(entity, data) { 21 | if (data === 0) { 22 | data = {}; 23 | } 24 | 25 | var component = new this.ComponentType(this, entity); 26 | var componentData = new this.DataType(); 27 | this.store[entity.getGuid()] = { 28 | entity, 29 | data: componentData 30 | }; 31 | 32 | component.originalData = data; 33 | entity[this.id] = component; 34 | entity.c[this.id] = component; 35 | this.initializeComponentData(component, data, []); 36 | this.fire('add', entity, component); 37 | return component; 38 | }; 39 | 40 | pc.Entity.prototype._cloneRecursively = function _cloneRecursively(duplicatedIdsMap) { 41 | var clone = new pc.Entity(this._app); 42 | 43 | pc.GraphNode.prototype._cloneInternal.call(this, clone); 44 | 45 | for (var type in this.c) { 46 | var component = this.c[type]; 47 | component.system.cloneComponent(this, clone); 48 | clone[type].originalData = component.originalData; 49 | } 50 | 51 | for (var i = 0; i < this._children.length; i++) { 52 | var oldChild = this._children[i]; 53 | 54 | if (oldChild instanceof pc.Entity) { 55 | var newChild = oldChild._cloneRecursively(duplicatedIdsMap); 56 | 57 | clone.addChild(newChild); 58 | duplicatedIdsMap[oldChild.getGuid()] = newChild; 59 | } 60 | } 61 | 62 | return clone; 63 | }; 64 | 65 | pc.Template.prototype.instantiate = function instantiate(app) { 66 | if (app) { 67 | this._app = app; 68 | } 69 | 70 | this._parseTemplate(); 71 | const entity = this._templateRoot.clone(); 72 | 73 | // TODO: HACK for client only scripts and entities. Maybe should remove it and implement system to create client-only scripts on backend in runtime 74 | for (const key in entity.script.originalData.scripts) { 75 | if (scripts.registry.has(key)) continue; 76 | const script = entity.script.originalData.scripts[key]; 77 | 78 | for (const attrKey in script.attributes) { 79 | const value = script.attributes[attrKey]; 80 | if (typeof value !== 'string') continue; 81 | if (!value.match(/^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i)) continue; 82 | 83 | const path = this._templateRoot.findByGuid(value).path; 84 | script.attributes[attrKey] = entity.findByPath(path).getGuid(); 85 | } 86 | } 87 | 88 | return entity; 89 | }; 90 | } 91 | 92 | async addApplication(app) { 93 | this.apps.set(app.room.id, { app, cache: new Map() }); 94 | await this.loadTemplates(app); 95 | const watcher = this.watch(app); 96 | 97 | app.once('destroy', () => { 98 | this.apps.delete(app.room.id); 99 | watcher.close(); 100 | }); 101 | } 102 | 103 | // watches directory for file changes, to hand template reloading 104 | watch(app) { 105 | const watcher = chokidar.watch(this.directory); 106 | 107 | watcher 108 | .on('add', async path => this.loadTemplate(path, app)) 109 | .on('change', async path => this.loadTemplate(path, app)) 110 | .on('unlink', async path => { 111 | const id = this.assetIdByPath.get(path); 112 | const asset = app.assets.get(id); 113 | if (asset) app.assets.remove(asset); 114 | this.apps.get(app.room.id).cache.delete(path); 115 | if (this.logging) console.log('removed template: ', path); 116 | }); 117 | 118 | return watcher; 119 | } 120 | 121 | async loadTemplates(app, directory = this.directory) { 122 | // Unify path to use same as chokidar 123 | directory = unifyPath(directory); 124 | 125 | const files = await fs.readdir(directory); 126 | 127 | for (let i = 0; i < files.length; i++) { 128 | const _path = `${directory}${path.sep}${files[i]}`; 129 | const stats = await fs.stat(_path); 130 | 131 | if (stats.isDirectory()) { 132 | await this.loadTemplates(app, _path); 133 | } else if (stats.isFile()) { 134 | await this.loadTemplate(_path, app); 135 | } 136 | } 137 | } 138 | 139 | async loadTemplate(path, app) { 140 | path = unifyPath(path); 141 | let data = await fs.readFile(path); 142 | data = data.toString(); 143 | 144 | const cache = this.apps.get(app.room.id).cache.get(path); 145 | if (cache === data) return; 146 | 147 | try { 148 | const json = JSON.parse(data); 149 | 150 | if (cache) { 151 | if (this.logging) console.log('reloading template: ', path); 152 | 153 | // update existing 154 | try { 155 | const asset = app.assets.get(json.id); 156 | if (asset) app.assets.remove(asset); 157 | this.createAsset(data, path, app); 158 | } catch (ex) { 159 | console.log('failed updating template: ', path); 160 | console.error(ex); 161 | } 162 | } else { 163 | try { 164 | // load new 165 | this.createAsset(data, path, app); 166 | } catch (ex) { 167 | console.log('failed hot-loading template: ', path); 168 | console.error(ex); 169 | } 170 | } 171 | } catch (ex) { 172 | console.error(ex); 173 | } 174 | } 175 | 176 | createAsset(data, fullPath, app) { 177 | try { 178 | const json = JSON.parse(data); 179 | const asset = new pc.Asset(json.name, json.type, null, json.data); 180 | // id 181 | asset.id = json.id; 182 | asset.preload = true; 183 | // tags 184 | for (let i = 0; i < json.tags.length; i++) { 185 | asset.tags.add(json.tags[i]); 186 | } 187 | 188 | app.assets.add(asset); 189 | app.assets.load(asset); 190 | 191 | this.apps.get(app.room.id).cache.set(fullPath, data); 192 | 193 | if (this.logging) console.log('new template asset', fullPath); 194 | return asset; 195 | } catch (ex) { 196 | console.log('failed creating asset', fullPath); 197 | console.error(ex); 198 | } 199 | 200 | return null; 201 | } 202 | } 203 | 204 | export default new Templates(); 205 | -------------------------------------------------------------------------------- /tests/utils/constants.js: -------------------------------------------------------------------------------- 1 | const testJson = { test: 'test' }; 2 | 3 | const circularJson = { }; 4 | circularJson.self = { circularJson }; 5 | 6 | const testLevel = { 7 | item_id: '1466632', 8 | branch_id: 'c0888619-8ecc-432d-9cc2-876042ec8c5f', 9 | scene: 1466632, 10 | project_id: 857037, 11 | name: 'Tests', 12 | settings: { 13 | physics: { gravity: [0, -9.8, 0] }, 14 | render: { 15 | fog_end: 1000, 16 | fog_start: 1, 17 | global_ambient: [0.2, 0.2, 0.2], 18 | fog_color: [0, 0, 0], 19 | fog: 'none', 20 | fog_density: 0.01, 21 | gamma_correction: 1, 22 | tonemapping: 0, 23 | exposure: 1, 24 | skybox: null, 25 | skyboxIntensity: 1, 26 | skyboxRotation: [0, 0, 0], 27 | skyboxMip: 0, 28 | lightmapSizeMultiplier: 16, 29 | lightmapMaxResolution: 2048, 30 | lightmapMode: 1, 31 | lightmapFilterEnabled: false, 32 | lightmapFilterRange: 10, 33 | lightmapFilterSmoothness: 0.2, 34 | ambientBake: false, 35 | ambientBakeNumSamples: 1, 36 | ambientBakeSpherePart: 0.4, 37 | ambientBakeOcclusionBrightness: 0, 38 | ambientBakeOcclusionContrast: 0 39 | } 40 | }, 41 | entities: { 42 | 'e6fed9aa-f422-4834-bddf-acc9c56ee364': { 43 | name: 'Root', 44 | parent: null, 45 | resource_id: 'e6fed9aa-f422-4834-bddf-acc9c56ee364', 46 | tags: [], 47 | enabled: true, 48 | components: {}, 49 | scale: [1, 1, 1], 50 | position: [0, 0, 0], 51 | rotation: [0, 0, 0], 52 | children: [ 53 | 'f31215f8-4327-46e5-8c73-5c94d056e078', 54 | '20554bb5-cc24-4bff-ba92-b8577b750cc4', 55 | '8ea9cdfd-2a5d-484f-96ac-9b03ceb45c6f', 56 | '31112a88-7798-405c-b212-c546e420c558' 57 | ] 58 | }, 59 | 'f31215f8-4327-46e5-8c73-5c94d056e078': { 60 | name: 'Camera', 61 | parent: 'e6fed9aa-f422-4834-bddf-acc9c56ee364', 62 | resource_id: 'f31215f8-4327-46e5-8c73-5c94d056e078', 63 | tags: [], 64 | enabled: true, 65 | components: { 66 | camera: { 67 | fov: 45, 68 | projection: 0, 69 | clearColor: [0.118, 0.118, 0.118, 1], 70 | clearColorBuffer: true, 71 | clearDepthBuffer: true, 72 | frustumCulling: true, 73 | enabled: true, 74 | orthoHeight: 4, 75 | farClip: 1000, 76 | nearClip: 0.1, 77 | priority: 0, 78 | rect: [0, 0, 1, 1], 79 | layers: [0, 1, 2, 3, 4] 80 | } 81 | }, 82 | scale: [1, 1, 1], 83 | position: [4, 3.5, 4], 84 | rotation: [-30, 45, 0], 85 | children: [] 86 | }, 87 | '20554bb5-cc24-4bff-ba92-b8577b750cc4': { 88 | name: 'Light', 89 | parent: 'e6fed9aa-f422-4834-bddf-acc9c56ee364', 90 | resource_id: '20554bb5-cc24-4bff-ba92-b8577b750cc4', 91 | tags: [], 92 | enabled: true, 93 | components: { 94 | light: { 95 | enabled: true, 96 | bake: false, 97 | bakeNumSamples: 1, 98 | bakeArea: 0, 99 | bakeDir: true, 100 | affectDynamic: true, 101 | affectLightmapped: false, 102 | isStatic: false, 103 | color: [1, 1, 1], 104 | intensity: 1, 105 | type: 'directional', 106 | shadowDistance: 16, 107 | range: 8, 108 | innerConeAngle: 40, 109 | outerConeAngle: 45, 110 | shape: 0, 111 | falloffMode: 0, 112 | castShadows: true, 113 | shadowUpdateMode: 2, 114 | shadowType: 0, 115 | shadowResolution: 1024, 116 | shadowBias: 0.4, 117 | normalOffsetBias: 0.05, 118 | vsmBlurMode: 1, 119 | vsmBlurSize: 11, 120 | vsmBias: 0.01, 121 | cookieAsset: null, 122 | cookieIntensity: 1, 123 | cookieFalloff: true, 124 | cookieChannel: 'rgb', 125 | cookieAngle: 0, 126 | cookieScale: [1, 1], 127 | cookieOffset: [0, 0], 128 | layers: [0] 129 | } 130 | }, 131 | scale: [1, 1, 1], 132 | position: [2, 2, -2], 133 | rotation: [45, 135, 0], 134 | children: [] 135 | }, 136 | '8ea9cdfd-2a5d-484f-96ac-9b03ceb45c6f': { 137 | name: 'Box', 138 | parent: 'e6fed9aa-f422-4834-bddf-acc9c56ee364', 139 | resource_id: '8ea9cdfd-2a5d-484f-96ac-9b03ceb45c6f', 140 | tags: [], 141 | enabled: true, 142 | components: { 143 | render: { 144 | enabled: true, 145 | type: 'box', 146 | asset: null, 147 | materialAssets: [null], 148 | castShadows: true, 149 | receiveShadows: true, 150 | lightmapped: false, 151 | lightmapSizeMultiplier: 1, 152 | castShadowsLightmap: true, 153 | rootBone: null, 154 | isStatic: false, 155 | layers: [0], 156 | batchGroupId: -1 157 | }, 158 | script: { 159 | enabled: true, 160 | order: ['networkEntity'], 161 | scripts: { networkEntity: { enabled: true, attributes: { id: 0, owner: '', properties: [] } } } 162 | } 163 | }, 164 | scale: [1, 1, 1], 165 | position: [0, 0.5, 0], 166 | rotation: [0, 0, 0], 167 | children: [] 168 | }, 169 | '31112a88-7798-405c-b212-c546e420c558': { 170 | name: 'Plane', 171 | parent: 'e6fed9aa-f422-4834-bddf-acc9c56ee364', 172 | resource_id: '31112a88-7798-405c-b212-c546e420c558', 173 | tags: [], 174 | enabled: true, 175 | components: { 176 | render: { 177 | enabled: true, 178 | type: 'plane', 179 | asset: null, 180 | materialAssets: [null], 181 | castShadows: true, 182 | receiveShadows: true, 183 | lightmapped: false, 184 | lightmapSizeMultiplier: 1, 185 | castShadowsLightmap: true, 186 | rootBone: null, 187 | isStatic: false, 188 | layers: [0], 189 | batchGroupId: -1 190 | } 191 | }, 192 | scale: [8, 1, 8], 193 | position: [0, 0, 0], 194 | rotation: [0, 0, 0], 195 | children: [] 196 | } 197 | }, 198 | created: '2022-07-07T12:33:08.639Z' 199 | }; 200 | 201 | export { testJson, circularJson, testLevel }; 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlayNetwork 2 | 3 | This is a solution to run [PlayCanvas engine](https://github.com/playcanvas/engine) on the back-end for authoritative multiplayer games and applications. Which takes care of network synchronization, mesh-communication, and allows you to focus on gameplay logic. 4 | 5 | This mainly focuses on a session/match-based type of application and is not a solution for MMOs. 6 | 7 | # [API Documentation](./docs/) 📄 8 | 9 | # Functionality 10 | 11 | - **Rooms** - each server process can run multiple rooms, each with its own Application context, own levels, users, and logic. 12 | - **Levels** (hierarchy) - is authored in PlayCanvas Editor, and can be easily sent to the back-end to be saved and used for rooms. When a client joins a room, the server will send a current hierarchy (parsed) and the client will instantiate it. 13 | - **Networked entities** - ensures that entities are synchronized between server and clients. Using the `properties` list, you can specify (by path) what data is synchronized and what properties are interpolated. 14 | - **Custom events** - allows to send custom events from client/server to: server, user, room or network entity. 15 | - **Code hot-reloading** - provides faster development times, without a need to restart a server. 16 | - **Interpolation** - the client can interpolate numbers, vectors, colors, and quaternions, this is done by checking `interpolate` on networked entity paths. 17 | - **Physics** - server can run Ammo.js physics within the Application context. It is also possible to run physics only on the server-side and keep the client only interpolating. 18 | - **Horizontal Scalability** - clients can be connected to a specific server, and be proxied to another server with actual application logic (room). By hosting multiple servers in different regions behind a load balancers, this provides seamless horizontal scalability. 19 | 20 | ### PlayNetwork 21 | 22 | Multiple server processes can be started. Each server process can run multiple rooms. 23 | 24 | ### Rooms 🌍 25 | 26 | Each room has its Application instance and a lifecycle. So it can have its scene hierarchy and scripts logic. It will run an update with a set `tickrate` 27 | 28 | ### Levels 🏠 29 | 30 | To start PlayCanvas Application, you need a scene hierarchy. Which you can create manually by code, or use scene loader to load it from JSON file. 31 | Server and client run their logic, and the code of scripts will differ between client and server but will share attributes. 32 | 33 | ### Networked entities 🏀 34 | 35 | Each networked entity gets a unique ID that is persistent between a server and clients. Any server-side change to an entity, components, or script attributes, if specified by a property in networked entity attributes, will automatically synchronize to the client. Additionally properties can be interpolated automatically. 36 | 37 | ### Custom events 📨 38 | 39 | Server and client can send to each other any variety of messages. The client can send messages to a Server, Room, User, and NetworkEntity, which then will be triggered on appropriate instances on the server-side, providing `sender` - as an author of a message. The server can also send messages to the client, to different scopes: User, Room (all Users), NetworkEntity (all Users, but specific Entity). 40 | Client sent messages also have a callback, which allows getting a response from a server, which is similar to RPC. 41 | 42 | ### Code hot-reloading 🔥 43 | 44 | For faster development times, it is possible to hot-reload script types, without restarting a server (what a blast!). Simply ensure you have a `swap` method defined, and the internal watcher will try to reload your script on every change/save. This will reload script types from an updated file, and trigger a `swap` method on all entities with such script type. If there was a parsing error, it will report that in the console and prevent hot-swap. 45 | 46 | For more details on how to inherit the old instance state read here: [User Manual](https://developer.playcanvas.com/en/user-manual/scripting/hot-reloading/). 47 | 48 | # Installation 49 | 50 | This project is made of two parts. 51 | 52 | ### Server 53 | 54 | Server-side code, that implements gameplay, rooms logic, serve level data, session, and APIs. 55 | 56 | ### Client 57 | 58 | And a client-side code, that communicates to a server, gets level data and instantiates it. It is recommended to use PlayCanvas Editor for ease of development, but an engine-only approach is a viable option too. 59 | 60 | # Example Project 🚀 61 | 62 | https://github.com/meta-space-org/playnetwork-example-3d-physics-topdown 63 | 64 | This project implements a simple top-down 3D game with physics. It uses client authority for the player controller entity and interpolates the game state. This example allows for the creation and to join rooms by the client. 65 | 66 | # Dependencies 67 | 68 | In order to implement multi-server architecture, PlayNetwork uses Redis for handling routing, unique IDs generation, cross-server communication and more. 69 | 70 | # Hosting and Configuration 71 | 72 | In order to handle high concurrent connections, we recommend running multiple servers and instances across various regions, and having a load balancers in front of them. This provides users with a fast connection to the closest instance while allowing spreading users between servers and rooms without constraining them to initially connected server. So any user can join any room in any region, without a need of new connections. 73 | 74 | Too much load in a region, just launch more instances and servers, and let load balancers to spread the load. 75 | 76 | Our recommended setup would be: 77 | 78 | ``` 79 | [domain] 80 | [load-balancer (latency/region)] 81 | 82 | [region-us] 83 | [load-balancer (cpu-load)] 84 | [instance-1] 85 | [server-1] 86 | [server-2] 87 | ... (as many as CPU Threads) 88 | 89 | [instance-2] 90 | [server-1] 91 | [server-2] 92 | ... 93 | 94 | ... 95 | 96 | [region-eu] 97 | 98 | ... 99 | ``` 100 | 101 | For optimal usage of instance resources and taking in account single-threaded nature of Node.js it is recommended to run multiple PlayNetwork processes on each instance, as many as there are CPU Threads on that instance. 102 | 103 | Each server should be bound to own port and be accessible individually either by public subdomains or inter-network addresses. 104 | 105 | # Debugging ❓ 106 | 107 | You can run a server with a debugger: `npm run debug`, then open Chrome and navigate to `chrome://inspect`, then click on `Open dedicated DevTools for Node`. 108 | 109 | You can use breakpoints and debug the same way as client-side. 110 | 111 | # Level Data 🏠 112 | 113 | Level (hierarchy) is stored on the server and is sent to the client when it connects to the room. It is up to you to implement a saving and method of storing level data. For an easier start, we provide a basic FileLevelProvider, which will save/load level JSON to a file. 114 | 115 | # Templates 📦 116 | 117 | It is convenient to use Templates on the server, to create complex entities. Templates are parsed and loaded from provided option `templatesPath` to `pn.initialize`. You can then access them as normal by ID: `this.app.assets.get(61886320)` in your Applications. 118 | 119 | In order to get Template JSON file, unfortunately it is not implemented in Editor yet: https://github.com/playcanvas/editor/issues/551 120 | So we can use Editor API in order to get JSON: 121 | 122 | 1. Open Editor 123 | 2. Select single Template asset 124 | 3. Open Dev Tools > Console 125 | 4. Execute in console: `JSON.stringify(editor.call('selector:items')[0].json(), null, 4)`; 126 | 5. Right-click on logged string > Copy string contents 127 | 6. Paste copied JSON into your template file. 128 | 129 | Then use its ID to get it from the registry. 130 | -------------------------------------------------------------------------------- /src/server/core/network-entities/network-entity.js: -------------------------------------------------------------------------------- 1 | import equal from 'fast-deep-equal'; 2 | import parsers from './parsers.js'; 3 | import { roundTo } from '../../libs/utils.js'; 4 | 5 | /** 6 | * @class NetworkEntity 7 | * @classdesc NetworkEntity is a {@link pc.ScriptType}, which is attached to a 8 | * {@link pc.ScriptComponent} of an {@link pc.Entity} that needs to be 9 | * synchronised between server and clients. It has unique ID, optional owner and 10 | * list of properties to be synchronised. For convenience, {@link pc.Entity} has 11 | * additional property: `entity.networkEntity`. 12 | * @extends pc.ScriptType 13 | * @property {string} id Unique identifier within a server. 14 | * @property {User} user Optional {@link User} to which this 15 | * {@link pc.Entity} is related. 16 | * @property {Object[]} properties List of properties, which should be 17 | * synchronised and optionally can be interpolated. Each property `object` has 18 | * these properties: 19 | * 20 | * 21 | * | Param | Type | Description | 22 | * | --- | --- | --- | 23 | * | path | `string` | Path to a property. | 24 | * | interpolate | `boolean` | If value is type of: `number` | `Vec2` | `Vec3` | `Vec4` | `Quat` | `Color`, then it can be interpolated. | 25 | * | ignoreForOwner | `boolean` | If `true` then server will not send this property updates to its related user. | 26 | */ 27 | 28 | /** 29 | * @event NetworkEntity#* 30 | * @description {@link NetworkEntity} will receive own named network messages. 31 | * @param {User} sender {@link User} that sent the message. 32 | * @param {object|array|string|number|boolean} [data] Message data. 33 | * @param {responseCallback} callback Callback that can be called to respond to a message. 34 | */ 35 | 36 | const NetworkEntity = pc.createScript('networkEntity'); 37 | 38 | NetworkEntity.attributes.add('id', { type: 'string', default: -1 }); 39 | NetworkEntity.attributes.add('owner', { type: 'string' }); 40 | NetworkEntity.attributes.add('properties', { 41 | title: 'Properties', 42 | type: 'json', 43 | array: true, 44 | description: 'List of property paths to be synchronised', 45 | schema: [ 46 | { type: 'string', name: 'path' }, 47 | { type: 'boolean', name: 'interpolate' }, 48 | { type: 'boolean', name: 'ignoreForOwner' } 49 | ] 50 | }); 51 | 52 | NetworkEntity.prototype.initialize = function() { 53 | this.entity.networkEntity = this; 54 | this.user = this.app.room.users.get(this.owner); 55 | 56 | this._pathParts = {}; 57 | this.cachedState = {}; 58 | this.invalidPaths = new Set(); 59 | 60 | // special rules 61 | this.rules = { 62 | parent: () => { 63 | return this.entity.parent?.getGuid() || null; 64 | }, 65 | localPosition: () => { 66 | const value = this.entity.getLocalPosition(); 67 | return { x: roundTo(value.x), y: roundTo(value.y), z: roundTo(value.z) }; 68 | }, 69 | localRotation: () => { 70 | const value = this.entity.getLocalRotation(); 71 | return { x: roundTo(value.x), y: roundTo(value.y), z: roundTo(value.z), w: roundTo(value.w) }; 72 | }, 73 | position: () => { 74 | const value = this.entity.getPosition(); 75 | return { x: roundTo(value.x), y: roundTo(value.y), z: roundTo(value.z) }; 76 | }, 77 | rotation: () => { 78 | const value = this.entity.getRotation(); 79 | return { x: roundTo(value.x), y: roundTo(value.y), z: roundTo(value.z), w: roundTo(value.w) }; 80 | }, 81 | scale: () => { 82 | const value = this.entity.getLocalScale(); 83 | return { x: roundTo(value.x), y: roundTo(value.y), z: roundTo(value.z) }; 84 | } 85 | }; 86 | 87 | this.once('destroy', this.onDestroy, this); 88 | }; 89 | 90 | NetworkEntity.prototype.postInitialize = function() { 91 | if (this.id) return; 92 | 93 | let parent = this.entity.parent; 94 | 95 | while (parent) { 96 | if (parent.networkEntity && !parent.networkEntity.id) return; 97 | parent = parent.parent; 98 | } 99 | 100 | this.app.room.networkEntities.create(this); 101 | }; 102 | 103 | NetworkEntity.prototype.swap = function(old) { 104 | this.user = old.user; 105 | this._pathParts = old._pathParts; 106 | this.cachedState = old.cachedState; 107 | this.invalidPaths = old.invalidPaths; 108 | this.rules = old.rules; 109 | 110 | // TODO: remove when playcanvas application will be destroyed properly 111 | // https://github.com/playcanvas/engine/issues/4135 112 | old.off('destroy', old.onDestroy, old); 113 | this.once('destroy', this.onDestroy, this); 114 | }; 115 | 116 | /** 117 | * @method send 118 | * @description Send a named message to a {@link NetworkEntity}. 119 | * It will be received by all clients that know about this NetworkEntity. 120 | * @param {string} name Name of a message. 121 | * @param {object|array|string|number|boolean} [data] JSON friendly message data. 122 | */ 123 | NetworkEntity.prototype.send = function(name, data) { 124 | for (const user of this.app.room.users.values()) { 125 | user._send(name, data, 'networkEntity', this.id); 126 | } 127 | }; 128 | 129 | NetworkEntity.prototype.onDestroy = function() { 130 | // TODO: remove when playcanvas application will be destroyed properly 131 | // https://github.com/playcanvas/engine/issues/4135 132 | this.user = null; 133 | }; 134 | 135 | NetworkEntity.prototype.propertyAdd = function(path) { 136 | if (this.properties.findIndex(p => p.path === path) === -1) 137 | return; 138 | 139 | this.properties.push({ path }); 140 | }; 141 | 142 | NetworkEntity.prototype.propertyRemove = function(path) { 143 | const ind = this.properties.findIndex(p => p.path === path); 144 | if (this.id === -1) return; 145 | this.properties.splice(ind, 1); 146 | }; 147 | 148 | NetworkEntity.prototype.getState = function(force) { 149 | const state = {}; 150 | 151 | for (let i = 0; i < this.properties.length; i++) { 152 | const path = this.properties[i].path; 153 | const parts = this._makePathParts(path); 154 | const rule = this.rules[path]; 155 | 156 | let node = this.entity; 157 | let cachedStateNode = this.cachedState; 158 | let stateNode = state; 159 | 160 | for (let p = 0; p < parts.length; p++) { 161 | const part = parts[p]; 162 | 163 | if (!rule && (node === null || node === undefined || node === {} || node[part] === undefined)) { 164 | if (!this.invalidPaths.has(path)) { 165 | console.warn(`Network entity "${this.entity.name}", id: ${this.id}. Property path "${path}" is leading to unexisting data`); 166 | this.invalidPaths.add(path); 167 | } 168 | 169 | break; 170 | } 171 | 172 | let value = null; 173 | 174 | if (p === (parts.length - 1)) { 175 | if (rule) { 176 | value = rule(); 177 | } else if (typeof (node[part]) === 'object' && node[part]) { 178 | const parser = parsers.get(node[part].constructor); 179 | if (!parser) continue; 180 | value = parser(node[part]); 181 | } else { 182 | value = node[part]; 183 | } 184 | 185 | if (force || !equal(value, cachedStateNode[part])) { 186 | cachedStateNode[part] = value; 187 | 188 | for (let i = 0; i < p; i++) { 189 | if (!stateNode[parts[i]]) 190 | stateNode[parts[i]] = {}; 191 | 192 | stateNode = stateNode[parts[i]]; 193 | } 194 | 195 | stateNode[part] = value; 196 | } 197 | } else { 198 | if (!cachedStateNode[part]) 199 | cachedStateNode[part] = {}; 200 | 201 | if (typeof (node[part]) === 'function') { 202 | node = node[part](); 203 | } else { 204 | node = node[part]; 205 | } 206 | 207 | cachedStateNode = cachedStateNode[part]; 208 | } 209 | } 210 | } 211 | 212 | if (Object.keys(state).length === 0) 213 | return null; 214 | 215 | state.id = this.id; 216 | state.owner = this.owner; 217 | 218 | return state; 219 | }; 220 | 221 | NetworkEntity.prototype._makePathParts = function(path) { 222 | let parts = this._pathParts[path]; 223 | if (!parts) { 224 | parts = path.split('.'); 225 | this._pathParts[path] = parts; 226 | } 227 | return parts; 228 | }; 229 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import './network-entities/network-entities.js'; 2 | import './user.js'; 3 | import './room.js'; 4 | import './levels.js'; 5 | import './interpolation.js'; 6 | 7 | /** 8 | * @class PlayNetwork 9 | * @classdesc Main interface to connect to a server and interact with networked data. 10 | * @extends pc.EventHandler 11 | * @property {User} me Local {@link User} object. 12 | * @property {Room} room {@link Room} that {@link User} has joined. 13 | * @property {number} latency Current network latency in miliseconds. 14 | * @property {Levels} levels Interface that allows to save hierarchy data to a server. 15 | */ 16 | 17 | /** 18 | * @callback responseCallback 19 | * @param {null|Error} error Error provided with with a response. 20 | * @param {null|object|array|string|number|boolean} data Data provided with a response. 21 | */ 22 | 23 | /** 24 | * @callback connectCallback 25 | * @param {null|Error} error Error if connection failed. 26 | * @param {null|User} user Own {@link User} object. 27 | */ 28 | 29 | /** 30 | * @callback joinRoomCallback 31 | * @param {null|Error} error Error if failed to join a {@link Room}. 32 | */ 33 | 34 | /** 35 | * @callback leaveRoomCallback 36 | * @param {null|Error} error Error if failed to leave a {@link Room}. 37 | */ 38 | 39 | /** 40 | * @callback createRoomCallback 41 | * @param {null|Error} error Error if failed to create a {@link Room}. 42 | * @param {null|number} roomId ID of a created {@link Room}. 43 | */ 44 | 45 | /** 46 | * @event PlayNetwork#connect 47 | * @description Fired when client has connected to a server and received own {@link User} data. 48 | * @param {User} user Own {@link User} instance. 49 | */ 50 | 51 | /** 52 | * @event PlayNetwork#disconnect 53 | * @description Fired after client has been disconnected from a server. 54 | */ 55 | 56 | /** 57 | * @event PlayNetwork#error 58 | * @description Fired when networking error occurs. 59 | * @param {Error} error 60 | */ 61 | 62 | /** 63 | * @event PlayNetwork#* 64 | * @description Fired on receive of a named network messages. 65 | * @param {null|object|array|string|number|boolean} [data] Message data. 66 | */ 67 | 68 | class PlayNetwork extends pc.EventHandler { 69 | constructor() { 70 | super(); 71 | 72 | this._lastId = 1; 73 | this._callbacks = new Map(); 74 | } 75 | 76 | initialize() { 77 | this.me = null; 78 | this.room = null; 79 | 80 | this.latency = 0; 81 | 82 | this.levels = new Levels(); 83 | 84 | this.roomMessagesQueue = []; 85 | this.roomPingsQueue = []; 86 | 87 | this.on('_room:join', async ({ tickrate, users, level, state, id }) => { 88 | this.room = new Room(id, tickrate, users); 89 | await this.levels.build(this.room, level); 90 | this.room.fire('_state:update', state); 91 | 92 | for (const msg of this.roomMessagesQueue) { 93 | if (msg.scope.id === this.room.id) 94 | this.room.fire(msg.name, msg.data); 95 | } 96 | 97 | for (const data of this.roomPingsQueue) { 98 | if (data.r === this.room.id) { 99 | this.room.latency = data.l; 100 | this.room.send('_pong'); 101 | } 102 | } 103 | }); 104 | 105 | this.on('_room:leave', () => { 106 | const room = this.room; 107 | this.room = null; 108 | room.destroy(); 109 | }); 110 | } 111 | 112 | /** 113 | * @method connect 114 | * @description Create a WebSocket connection to the PlayNetwork server. 115 | * @param {string} host Host of a server. 116 | * @param {number} port Port of a server. 117 | * @param {boolean} useSSL Use secure connection. 118 | * @param {object|array|string|number|boolean|null} payload Client authentication data. 119 | * @param {connectCallback} callback Will be fired when connection is succesfull or failed. 120 | */ 121 | connect(host, port, useSSL, payload, callback) { 122 | this.socket = new WebSocket(`${useSSL ? 'wss' : 'ws'}://${host}${port ? `:${port}` : ''}/websocket`); 123 | 124 | this.socket.onmessage = (e) => this._onMessage(e.data); 125 | 126 | this.socket.onopen = () => { 127 | this._send('_authenticate', payload, null, null, (err, data) => { 128 | if (err) { 129 | if (callback) callback(err, null); 130 | this.fire('error', err); 131 | return; 132 | } 133 | 134 | const user = new User(data, true); 135 | this.me = user; 136 | 137 | if (callback) callback(null, user); 138 | this.fire('connect', user); 139 | }); 140 | }; 141 | 142 | this.socket.onclose = () => { 143 | this.latency = 0; 144 | this.fire('disconnect'); 145 | }; 146 | 147 | this.socket.onerror = (err) => { 148 | this.fire('error', err); 149 | }; 150 | } 151 | 152 | /** 153 | * @method createRoom 154 | * @description Send a request to a server, to create a {@link Room}. 155 | * @param {object} data Request data that can be used by Server to decide room creation. 156 | * @param {createRoomCallback} callback Will be fired when room is created or failed. 157 | */ 158 | createRoom(data, callback) { 159 | this.send('_room:create', data, (err, roomId) => { 160 | if (!callback) return; 161 | 162 | if (err) { 163 | callback(err); 164 | } else { 165 | callback(null, roomId); 166 | } 167 | }); 168 | } 169 | 170 | /** 171 | * @method joinRoom 172 | * @description Send a request to a server, to join a {@link Room}. 173 | * @param {number} id ID of a {@link Room} to join. 174 | * @param {joinRoomCallback} callback Will be fired when {@link Room} is joined or failed. 175 | */ 176 | joinRoom(id, callback) { 177 | if (this.room?.id === id) { 178 | if (callback) callback(new Error(`Already joined a Room ${id}`)); 179 | return; 180 | } 181 | 182 | this.send('_room:join', id, (err) => { 183 | if (callback) callback(err || null); 184 | }); 185 | } 186 | 187 | /** 188 | * @method leaveRoom 189 | * @description Send a request to a server, to leave current {@link Room}. 190 | * @param {leaveRoomCallback} callback Will be fired when {@link Room} is left or failed. 191 | */ 192 | leaveRoom(callback) { 193 | if (!this.room) { 194 | if (callback) callback(new Error('Not in a Room')); 195 | return; 196 | } 197 | 198 | this.send('_room:leave', null, (err) => { 199 | if (callback) callback(err || null); 200 | }); 201 | } 202 | 203 | /** 204 | * @method send 205 | * @description Send named message to the server. 206 | * @param {string} name Name of a message. 207 | * @param {object|array|string|number|boolean} [data] JSON friendly message data. 208 | * @param {responseCallback} [callback] Callback that will be fired when a response message is received. 209 | */ 210 | send(name, data, callback) { 211 | this._send(name, data, 'server', null, callback); 212 | } 213 | 214 | _send(name, data, scope, id, callback) { 215 | const msg = { 216 | name, 217 | scope: { 218 | type: scope, 219 | id: id 220 | }, 221 | data 222 | }; 223 | 224 | if (callback) { 225 | msg.id = this._lastId; 226 | this._callbacks.set(this._lastId, callback); 227 | this._lastId++; 228 | } 229 | 230 | this.socket.send(JSON.stringify(msg)); 231 | } 232 | 233 | _onMessage(data) { 234 | const msg = JSON.parse(data); 235 | 236 | if (msg.id) { 237 | const callback = this._callbacks.get(msg.id); 238 | 239 | if (!callback) { 240 | console.warn(`No callback with id - ${msg.id}`); 241 | return; 242 | } 243 | 244 | if (msg.data?.err) { 245 | callback(new Error(msg.data?.err || ''), null); 246 | } else { 247 | callback(null, msg.data); 248 | } 249 | 250 | this._callbacks.delete(msg.id); 251 | } 252 | 253 | if (msg.data?.err) { 254 | console.warn(msg.data.err); 255 | return; 256 | } 257 | 258 | if (msg.id) return; 259 | 260 | switch (msg.scope?.type) { 261 | case 'user': 262 | this.me?.fire(msg.name, msg.data); 263 | break; 264 | case 'room': 265 | if (!this.room) { 266 | this.roomMessagesQueue.push(msg); 267 | break; 268 | } 269 | this.room.fire(msg.name, msg.data); 270 | break; 271 | case 'networkEntity': 272 | this.room.networkEntities.get(msg.scope.id)?.fire(msg.name, msg.data); 273 | break; 274 | } 275 | 276 | if (msg.name === '_ping' && this.me) this._onPing(msg.data); 277 | this.fire(msg.name, msg.data); 278 | } 279 | 280 | _onPing(data) { 281 | if (!data.r) { 282 | this.latency = data.l; 283 | this.me.send('_pong', data.id); 284 | return; 285 | } 286 | 287 | if (!this.room || data.r !== this.room.id) { 288 | this.roomPingsQueue.push(data); 289 | return; 290 | } 291 | 292 | this.room.latency = data.l; 293 | this.room.send('_pong'); 294 | } 295 | } 296 | 297 | window.pn = new PlayNetwork(); 298 | window.pn.initialize(); 299 | 300 | pc.ScriptComponent.prototype._scriptMethod = function(script, method, arg) { 301 | try { 302 | script[method](arg); 303 | } catch (ex) { 304 | script.enabled = false; 305 | console.warn(`unhandled exception while calling "${method}" for "${script.__scriptType.__name}" script: `, ex); 306 | console.error(ex); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/server/libs/scripts.js: -------------------------------------------------------------------------------- 1 | import vm from 'vm'; 2 | import fs from 'fs/promises'; 3 | import chokidar from 'chokidar'; 4 | import path from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | import { unifyPath } from './utils.js'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | 11 | const components = ['x', 'y', 'z', 'w']; 12 | function rawToValue(app, args, value, old) { 13 | const vecLookup = [undefined, undefined, pc.Vec2, pc.Vec3, pc.Vec4]; 14 | 15 | switch (args.type) { 16 | case 'boolean': 17 | return !!value; 18 | case 'number': 19 | if (typeof value === 'number') { 20 | return value; 21 | } else if (typeof value === 'string') { 22 | const v = parseInt(value, 10); 23 | if (isNaN(v)) return null; 24 | return v; 25 | } else if (typeof value === 'boolean') { 26 | return 0 + value; 27 | } 28 | 29 | return null; 30 | case 'json': 31 | var result = {}; 32 | 33 | if (Array.isArray(args.schema)) { 34 | if (!value || typeof value !== 'object') { 35 | value = {}; 36 | } 37 | 38 | for (let i = 0; i < args.schema.length; i++) { 39 | const field = args.schema[i]; 40 | if (!field.name) continue; 41 | 42 | if (field.array) { 43 | result[field.name] = []; 44 | const arr = Array.isArray(value[field.name]) ? value[field.name] : []; 45 | 46 | for (let j = 0; j < arr.length; j++) { 47 | result[field.name].push(rawToValue(app, field, arr[j])); 48 | } 49 | } else { 50 | const val = value.hasOwnProperty(field.name) ? value[field.name] : field.default; 51 | result[field.name] = rawToValue(app, field, val); 52 | } 53 | } 54 | } 55 | 56 | return result; 57 | case 'asset': 58 | if (value instanceof pc.Asset) { 59 | return value; 60 | } else if (typeof value === 'number') { 61 | return app.assets.get(value) || value; 62 | } else if (typeof value === 'string') { 63 | return app.assets.get(parseInt(value, 10)) || parseInt(value, 10); 64 | } 65 | 66 | return null; 67 | case 'entity': 68 | if (value instanceof pc.GraphNode) { 69 | return value; 70 | } else if (typeof value === 'string') { 71 | return app.getEntityFromIndex(value); 72 | } 73 | 74 | return null; 75 | case 'rgb': 76 | case 'rgba': 77 | if (value instanceof pc.Color) { 78 | if (old instanceof pc.Color) { 79 | old.copy(value); 80 | return old; 81 | } 82 | 83 | return value.clone(); 84 | } else if (value instanceof Array && value.length >= 3 && value.length <= 4) { 85 | for (let _i = 0; _i < value.length; _i++) { 86 | if (typeof value[_i] !== 'number') return null; 87 | } 88 | 89 | if (!old) old = new pc.Color(); 90 | old.r = value[0]; 91 | old.g = value[1]; 92 | old.b = value[2]; 93 | old.a = value.length === 3 ? 1 : value[3]; 94 | return old; 95 | } else if (typeof value === 'string' && /#([0-9abcdef]{2}){3,4}/i.test(value)) { 96 | if (!old) old = new pc.Color(); 97 | old.fromString(value); 98 | return old; 99 | } 100 | 101 | return null; 102 | case 'vec2': 103 | case 'vec3': 104 | case 'vec4': 105 | var len = parseInt(args.type.slice(3), 10); 106 | var vecType = vecLookup[len]; 107 | 108 | if (value instanceof vecType) { 109 | if (old instanceof vecType) { 110 | old.copy(value); 111 | return old; 112 | } 113 | 114 | return value.clone(); 115 | } else if (value instanceof Array && value.length === len) { 116 | for (let _i2 = 0; _i2 < value.length; _i2++) { 117 | if (typeof value[_i2] !== 'number') return null; 118 | } 119 | 120 | if (!old) old = new vecType(); 121 | 122 | for (let _i3 = 0; _i3 < len; _i3++) { 123 | old[components[_i3]] = value[_i3]; 124 | } 125 | 126 | return old; 127 | } 128 | 129 | return null; 130 | case 'curve': 131 | if (value) { 132 | let curve; 133 | 134 | if (value instanceof pc.Curve || value instanceof pc.CurveSet) { 135 | curve = value.clone(); 136 | } else { 137 | const CurveType = value.keys[0] instanceof Array ? pc.CurveSet : pc.Curve; 138 | curve = new CurveType(value.keys); 139 | curve.type = value.type; 140 | } 141 | 142 | return curve; 143 | } 144 | 145 | break; 146 | } 147 | 148 | return value; 149 | } 150 | 151 | class Scripts { 152 | sources = new Map(); 153 | 154 | async initialize(directory) { 155 | this.directory = directory; 156 | // global script registry 157 | this.registry = new pc.ScriptRegistry(); 158 | 159 | // pc.createScript should be modified 160 | // to add scripts to a global scripts registry instead of individual Applications 161 | const createScript = pc.createScript; 162 | const mockApp = { scripts: this.registry }; 163 | pc.createScript = function(name) { 164 | return createScript(name, mockApp); 165 | }; 166 | 167 | pc.ScriptAttributes.prototype.add = function add(name, args) { 168 | if (this.index[name]) { 169 | return; 170 | } else if (pc.ScriptAttributes.reservedNames.has(name)) { 171 | return; 172 | } 173 | 174 | this.index[name] = args; 175 | Object.defineProperty(this.scriptType.prototype, name, { 176 | get: function get() { 177 | return this.__attributes[name]; 178 | }, 179 | set: function set(raw) { 180 | const evt = 'attr'; 181 | const evtName = 'attr:' + name; 182 | const old = this.__attributes[name]; 183 | let oldCopy = old; 184 | 185 | if (old && args.type !== 'json' && old.clone) { 186 | if (this._callbacks[evt] || this._callbacks[evtName]) { 187 | oldCopy = old.clone(); 188 | } 189 | } 190 | 191 | if (args.array) { 192 | this.__attributes[name] = []; 193 | 194 | if (raw) { 195 | for (let i = 0, len = raw.length; i < len; i++) { 196 | this.__attributes[name].push(rawToValue(this.app, args, raw[i], old ? old[i] : null)); 197 | } 198 | } 199 | } else { 200 | this.__attributes[name] = rawToValue(this.app, args, raw, old); 201 | } 202 | 203 | this.fire(evt, name, this.__attributes[name], oldCopy); 204 | this.fire(evtName, this.__attributes[name], oldCopy); 205 | } 206 | }); 207 | }; 208 | 209 | pc.ScriptComponent.prototype._scriptMethod = function(script, method, arg) { 210 | try { 211 | script[method](arg); 212 | } catch (ex) { 213 | script.enabled = false; 214 | throw ex; 215 | } 216 | } 217 | 218 | // load network-entity script 219 | await import('../core/network-entities/network-entity.js'); 220 | 221 | // load all script components 222 | await this.loadDirectory(); 223 | 224 | // hot-reloading watcher 225 | this.watch(); 226 | } 227 | 228 | // load all scripts 229 | async loadDirectory(directory = this.directory) { 230 | // Unify path to use same as chokidar 231 | directory = unifyPath(directory); 232 | 233 | try { 234 | const items = await fs.readdir(directory); 235 | 236 | for (let i = 0; i < items.length; i++) { 237 | let filePath = `${directory}${path.sep}${items[i]}`; 238 | const stats = await fs.stat(filePath); 239 | 240 | if (stats.isFile()) { 241 | filePath = unifyPath(filePath); 242 | const data = await fs.readFile(filePath); 243 | this.sources.set(filePath, data.toString()); 244 | 245 | filePath = path.relative(path.dirname(__filename), `${path.resolve()}\\${filePath}`); 246 | 247 | await import('./' + filePath.replace(/\\/g, '/')); 248 | } else if (stats.isDirectory()) { 249 | await this.loadDirectory(filePath); 250 | } 251 | } 252 | } catch (ex) { 253 | console.error(ex); 254 | } 255 | } 256 | 257 | // watches directory for file changes, to handle code hot-reloading 258 | watch() { 259 | const watcher = chokidar.watch(this.directory); 260 | 261 | watcher.on('change', async (path) => { 262 | path = unifyPath(path); 263 | const data = await fs.readFile(path); 264 | const source = data.toString(); 265 | 266 | if (this.sources.get(path) === source) 267 | return; 268 | 269 | this.sources.set(path, source); 270 | 271 | try { 272 | vm.runInNewContext(data, global, path); 273 | } catch (ex) { 274 | console.error(ex); 275 | } 276 | }) 277 | } 278 | } 279 | 280 | export default new Scripts(); 281 | -------------------------------------------------------------------------------- /docs.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import ejs from 'ejs'; 4 | import jsdocApi from 'jsdoc-api'; 5 | import jsdocParse from 'jsdoc-parse'; 6 | 7 | if (process.argv.length < 5) { 8 | console.log('no enough arguments: docs folder, input files'); 9 | process.exit(0); 10 | } 11 | 12 | const title = process.argv[2]; 13 | const scope = title.toLowerCase(); 14 | const outputDir = process.argv[3]; 15 | const options = { 16 | files: process.argv.slice(4) 17 | }; 18 | 19 | const data = jsdocApi.explainSync(options); 20 | const templateData = jsdocParse(data, options); 21 | 22 | const indexClasses = new Map(); 23 | const indexFilenameToClass = new Map(); 24 | const indexCallbacks = new Map(); 25 | const homeLinks = new Map(); 26 | const indexLinks = new Map([ 27 | ['pc.Application', 'https://developer.playcanvas.com/en/api/pc.Application.html'], 28 | ['pc.EventHandler', 'https://developer.playcanvas.com/en/api/pc.EventHandler.html'], 29 | ['pc.Entity', 'https://developer.playcanvas.com/en/api/pc.Entity.html'], 30 | ['pc.ScriptType', 'https://developer.playcanvas.com/en/api/pc.ScriptType.html'], 31 | ['pc.ScriptComponent', 'https://developer.playcanvas.com/en/api/pc.ScriptComponent.html'], 32 | ['pc.Vec2', 'https://developer.playcanvas.com/en/api/pc.Vec2.html'], 33 | ['pc.Vec3', 'https://developer.playcanvas.com/en/api/pc.Vec3.html'], 34 | ['pc.Vec4', 'https://developer.playcanvas.com/en/api/pc.Vec4.html'], 35 | ['pc.Quat', 'https://developer.playcanvas.com/en/api/pc.Quat.html'], 36 | ['pc.Color', 'https://developer.playcanvas.com/en/api/pc.Color.html'], 37 | ['Set', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set'], 38 | ['Map', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map'], 39 | ['Error', 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error'], 40 | ['Redis', 'https://redis.io/'] 41 | ]); 42 | 43 | const replaceTypeLinks = function(items, classItem) { 44 | for (let i = 0; i < items.length; i++) { 45 | const className = items[i]; 46 | 47 | if (className.startsWith('Set.<')) { 48 | const memberName = className.slice(5, -1); 49 | classItem.links.set('Set', indexLinks.get('Set')); 50 | if (indexLinks.has(memberName)) { 51 | classItem.links.set(memberName, indexLinks.get(memberName)); 52 | items[i] = `[Set]<[${memberName}]>`; 53 | } else { 54 | items[i] = `[Set]<\`${memberName}\`>`; 55 | } 56 | } else if (className.startsWith('Map.<')) { 57 | const memberNames = className.slice(5, -1).split(','); 58 | const formattedMembers = []; 59 | classItem.links.set('Map', indexLinks.get('Map')); 60 | 61 | for (let memberName of memberNames) { 62 | memberName = memberName.trim(); 63 | if (indexLinks.has(memberName)) { 64 | classItem.links.set(memberName, indexLinks.get(memberName)); 65 | formattedMembers.push(`[${memberName}]`); 66 | } else { 67 | formattedMembers.push(`\`${memberName}\``); 68 | } 69 | } 70 | 71 | items[i] = `[Map]<${formattedMembers.join(', ')}>`; 72 | } else if (indexLinks.has(className)) { 73 | classItem.links.set(className, indexLinks.get(className)); 74 | items[i] = `[${className}]`; 75 | } else if (indexCallbacks.has(className)) { 76 | classItem.callbacks.add(indexCallbacks.get(className)); 77 | items[i] = `${className}`; 78 | } else { 79 | items[i] = `\`${className}\``; 80 | } 81 | } 82 | }; 83 | 84 | const replaceLinks = function(text, classItem, home) { 85 | return text.replace(/\r\|/g, '{|}').replace(/\r\r/g, '{n}').replace(/\r/g, ' ').replace(/\{\|\}/g, '\n|').replace(/\{n\}/g, '\n').replace(/\{@link ([a-zA-Z0-9._]+)\}/g, (part, className) => { 86 | if (indexLinks.has(className)) { 87 | classItem.links.set(className, indexLinks.get(className)); 88 | if (home) homeLinks.set(className, indexLinks.get(className)); 89 | return `[${className}]`; 90 | } else { 91 | return `\`${className}\``; 92 | } 93 | }); 94 | }; 95 | 96 | // index items 97 | for (let i = 0; i < templateData.length; i++) { 98 | const item = templateData[i]; 99 | 100 | // class 101 | if (item.kind === 'class' && !indexClasses.has(item.name)) { 102 | item.links = new Map(); 103 | item.functions = new Set(); 104 | item.events = new Set(); 105 | item.callbacks = new Set(); 106 | item.constructor = null; 107 | item.scope = scope; 108 | indexClasses.set(item.name, item); 109 | indexLinks.set(item.name, `./${item.name}.md`); 110 | indexFilenameToClass.set(item.meta.filename, item); 111 | if (!item.properties) item.properties = []; 112 | item.description = item.description || ''; 113 | item.extends = item.augments ? item.augments[0] : null; 114 | 115 | // replace extends links 116 | if (item.extends) { 117 | if (indexLinks.has(item.extends)) { 118 | item.links.set(item.extends, indexLinks.get(item.extends)); 119 | item.extends = `[${item.extends}]`; 120 | } else { 121 | item.extends = `\`${item.extends}\``; 122 | } 123 | } 124 | } 125 | 126 | // callback 127 | if (item.kind === 'typedef' && item.type.names[0] === 'function') { 128 | indexCallbacks.set(item.name, item); 129 | item.description = item.description || ''; 130 | } 131 | } 132 | 133 | // add functions and events to classes 134 | for (let i = 0; i < templateData.length; i++) { 135 | const item = templateData[i]; 136 | 137 | if (item.kind === 'constructor') { 138 | item.class = indexClasses.get(item.memberof); 139 | if (!item.class) continue; 140 | item.class.constructor = item; 141 | 142 | item.description = replaceLinks(item.description || '', item.class); 143 | if (!item.params) item.params = []; 144 | } 145 | 146 | if (item.kind === 'function') { 147 | item.class = indexFilenameToClass.get(item.meta.filename); 148 | if (!item.class) continue; 149 | item.class.functions.add(item); 150 | 151 | item.description = replaceLinks(item.description || '', item.class); 152 | if (!item.params) item.params = []; 153 | 154 | // add return type links 155 | if (item.returns) { 156 | for (let p = 0; p < item.returns.length; p++) { 157 | replaceTypeLinks(item.returns[p].type.names, item.class); 158 | } 159 | } 160 | } 161 | 162 | if (item.kind === 'event') { 163 | item.class = indexFilenameToClass.get(item.meta.filename); 164 | if (!item.class) continue; 165 | item.class.events.add(item); 166 | 167 | item.description = replaceLinks(item.description || '', item.class); 168 | if (!item.params) item.params = []; 169 | } 170 | 171 | // process params 172 | if ((item.kind === 'function' || item.kind === 'event' || item.kind === 'constructor') && item.params && item.class) { 173 | for (let p = 0; p < item.params.length; p++) { 174 | // add links to param description 175 | item.params[p].description = replaceLinks(item.params[p].description || '', item.class); 176 | replaceTypeLinks(item.params[p].type.names, item.class); 177 | } 178 | } 179 | } 180 | 181 | for (const classItem of indexClasses.values()) { 182 | if (classItem.description) 183 | classItem.description = replaceLinks(classItem.description.replace(/\r/g, ' '), classItem, true); 184 | 185 | for (let i = 0; i < classItem.properties.length; i++) { 186 | classItem.properties[i].description = replaceLinks(classItem.properties[i].description || '', classItem); 187 | 188 | replaceTypeLinks(classItem.properties[i].type.names, classItem); 189 | } 190 | 191 | for (const callbackItem of classItem.callbacks) { 192 | for (let p = 0; p < callbackItem.params.length; p++) { 193 | callbackItem.params[p].description = replaceLinks(callbackItem.params[p].description || '', classItem); 194 | replaceTypeLinks(callbackItem.params[p].type.names, classItem); 195 | } 196 | } 197 | } 198 | 199 | // get an index template 200 | const indexTemplateString = fs.readFileSync('./docs/templates/index.ejs').toString(); 201 | const indexTemplate = ejs.compile(indexTemplateString, { 202 | filename: './docs/templates/index.ejs' 203 | }); 204 | 205 | // render index 206 | fs.writeFileSync(path.resolve(outputDir, 'README.md'), indexTemplate({ 207 | title: title, 208 | classes: indexClasses, 209 | links: homeLinks 210 | })); 211 | 212 | // get a class template 213 | const classTemplateString = fs.readFileSync('./docs/templates/class.ejs').toString(); 214 | const classTemplate = ejs.compile(classTemplateString, { 215 | filename: './docs/templates/class.ejs' 216 | }); 217 | 218 | // render each class 219 | for (const [className, classItem] of indexClasses) { 220 | fs.writeFileSync(path.resolve(outputDir, `${className}.md`), classTemplate(classItem)); 221 | } 222 | 223 | // 224 | // main index file 225 | // 226 | 227 | const globalLinks = new Map(); 228 | 229 | const processIndexFile = function(path, scope) { 230 | let data = fs.readFileSync(path).toString(); 231 | // replace all links 232 | data = data.replaceAll(/((: )|(='))\.\/([a-zA-Z.0-9]+?.md)/g, `$1./${scope}/$4`); 233 | 234 | const links = []; 235 | 236 | const items = data.matchAll(/^(\[[a-zA-Z0-9.]+\]):\s+(.+)$/gm); 237 | for (const item of items) { 238 | if (item[2].startsWith('./')) { 239 | links.push(item); 240 | } else { 241 | globalLinks.set(item[1], item[2].trim()); 242 | } 243 | data = data.replace(item[0] + '\r\n', ''); 244 | } 245 | 246 | for (let i = 0; i < links.length; i++) { 247 | const item = links[i]; 248 | data = data.replaceAll(item[1], `${item[1]}(${item[2].trim()})`); 249 | } 250 | 251 | return data; 252 | }; 253 | 254 | const readmeServer = processIndexFile('./docs/server/README.md', 'server'); 255 | const readmeClient = processIndexFile('./docs/client/README.md', 'client'); 256 | 257 | // global links 258 | let links = ''; 259 | for (const [linkName, linkHref] of globalLinks) { 260 | links += linkName + ': ' + linkHref + ' \r\n'; 261 | } 262 | 263 | // write index file 264 | fs.writeFileSync('./docs/README.md', `# API Documentation\n\n${readmeServer}\n\n${readmeClient}\n${links}`); 265 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as https from 'https'; 3 | import * as pc from 'playcanvas'; 4 | import console from './libs/logger.js'; 5 | import WebSocket from 'faye-websocket'; 6 | import deflate from 'permessage-deflate'; 7 | import { downloadAsset, updateAssets } from './libs/assets.js'; 8 | 9 | import User from './core/user.js'; 10 | import performance from './libs/performance.js'; 11 | 12 | import levels from './libs/levels.js'; 13 | import scripts from './libs/scripts.js'; 14 | import templates from './libs/templates.js'; 15 | 16 | import Servers from './core/servers.js'; 17 | import Rooms from './core/rooms.js'; 18 | import Users from './core/users.js'; 19 | 20 | import Ammo from './libs/ammo.js'; 21 | 22 | import { createClient } from 'redis'; 23 | 24 | global.pc = {}; 25 | for (const key in pc) { 26 | global.pc[key] = pc[key]; 27 | } 28 | 29 | /** 30 | * @class PlayNetwork 31 | * @classdesc Main interface of PlayNetwork server. This class handles clients connection and communication. 32 | * @extends pc.EventHandler 33 | * @property {number} id Numerical ID of the server. 34 | * @property {Users} users {@link Users} interface that stores all connected users. 35 | * @property {Rooms} rooms {@link Rooms} interface that stores all rooms and handles new {@link Rooms} creation. 36 | * @property {Map} networkEntities Map of all {@link NetworkEntity}s created by this server. 37 | * @property {number} cpuLoad Current CPU load 0..1. 38 | * @property {number} memory Current memory usage in bytes. 39 | */ 40 | 41 | /** 42 | * @callback responseCallback 43 | * @param {null|Error} error Error provided with a response. 44 | * @param {null|object|array|string|number|boolean} data Data provided with a response. 45 | */ 46 | 47 | /** 48 | * @event PlayNetwork#error 49 | * @description Unhandled error. 50 | * @param {Error} error {@link Error} object. 51 | */ 52 | 53 | /** 54 | * @event PlayNetwork#* 55 | * @description {@link PlayNetwork} will receive own named network messages. Those messages are sent by the clients. 56 | * @param {User} sender User that sent the message. 57 | * @param {object|array|string|number|boolean} [data] Message data. 58 | * @param {responseCallback} callback Callback that can be called to respond to a message. 59 | */ 60 | 61 | class PlayNetwork extends pc.EventHandler { 62 | constructor() { 63 | super(); 64 | 65 | this.id = null; 66 | this.port = null; 67 | 68 | this.servers = new Servers(); 69 | this.users = new Users(); 70 | this.rooms = new Rooms(); 71 | this.networkEntities = new Map(); 72 | 73 | this._reservedEvents = ['destroy']; 74 | 75 | process.on('uncaughtException', (err) => { 76 | console.error(err); 77 | this.fire('error', err); 78 | return true; 79 | }); 80 | 81 | process.on('unhandledRejection', (err, promise) => { 82 | console.error(err); 83 | err.promise = promise; 84 | this.fire('error', err); 85 | return true; 86 | }); 87 | 88 | process.once('SIGINT', async () => { 89 | await this.destroy(); 90 | }); 91 | 92 | // FIX for pm2 graceful shutdown 93 | process.on('message', async (msg) => { 94 | if (msg !== 'shutdown') return; 95 | await this.destroy(); 96 | }); 97 | } 98 | 99 | /** 100 | * @method start 101 | * @description Start PlayNetwork, by providing configuration parameters. 102 | * @async 103 | * @param {object} settings Object with settings for initialization. 104 | * @param {string} settings.redisUrl URL of a {@link Redis} server. 105 | * @param {string} settings.websocketUrl Publicly or inter-network accessible URL to this servers WebSocket endpoint. 106 | * @param {string} settings.scriptsPath Relative path to script components. 107 | * @param {string} settings.templatesPath Relative path to templates. 108 | * @param {object} settings.levelProvider Instance of a level provider. 109 | * @param {http.Server|https.Server} settings.server Instance of a http(s) server. 110 | */ 111 | async start(settings) { 112 | const startTime = Date.now(); 113 | 114 | this._validateSettings(settings); 115 | await this.connectRedis(settings.redisUrl); 116 | 117 | this.port = settings.server.address().port; 118 | this.id = await this.generateId('server', settings.websocketUrl || 'null'); 119 | 120 | if (settings.useAmmo) global.Ammo = await new Ammo(); 121 | 122 | await levels.initialize(settings.levelProvider); 123 | await scripts.initialize(settings.scriptsPath); 124 | await templates.initialize(settings.templatesPath); 125 | this.rooms.initialize(); 126 | 127 | settings.server.on('upgrade', (req, ws, body) => { 128 | if (!WebSocket.isWebSocket(req)) return; 129 | 130 | const extensions = req.headers['user-agent'] === 'PlayNetwork' ? [] : [deflate]; 131 | 132 | let socket = new WebSocket(req, ws, body, extensions); 133 | let user = null; 134 | 135 | socket.on('open', async () => { }); 136 | 137 | socket.on('message', async (e) => { 138 | if (typeof e.data !== 'string') { 139 | e.rawData = e.data.rawData; 140 | e.data = e.data.data.toString('utf8', 0, e.data.data.length); 141 | } else { 142 | e.rawData = e.data; 143 | } 144 | 145 | e.msg = JSON.parse(e.data); 146 | 147 | const callback = (err, data) => { 148 | if (!err && !e.msg.id) return; 149 | 150 | const msg = { name: e.msg.name, data: err ? { err: err.message } : data, id: e.msg.id }; 151 | 152 | if (!e.msg.userId) { 153 | socket.send(JSON.stringify(msg)); 154 | return; 155 | }; 156 | 157 | this.users.get(e.msg.userId).then(u => { 158 | if (!u.serverId) { 159 | socket.send(JSON.stringify(msg)); 160 | return; 161 | } 162 | 163 | u._send(msg.name, msg.data, 'user', u.id, e.msg.data?._origCallbackId || e.msg.id); 164 | }); 165 | }; 166 | 167 | if (e.msg.name === '_authenticate') return socket.emit('_authenticate', e.msg.data, callback); 168 | 169 | const sender = e.msg.userId ? await this.users.get(e.msg.userId) : user; 170 | await this._onMessage(e.msg, sender, callback); 171 | }); 172 | 173 | socket.on('close', async () => { 174 | if (user) { 175 | await user.destroy(); 176 | } 177 | 178 | socket = null; 179 | }); 180 | 181 | socket.on('_authenticate', async (payload, callback) => { 182 | const connectUser = (id) => { 183 | user = new User(id, socket); 184 | this.users.add(user); 185 | callback(null, user.id); 186 | console.log(`User ${user.id} connected`); 187 | }; 188 | 189 | if (!this.users.hasEvent('authenticate')) { 190 | const id = await this.generateId('user'); 191 | connectUser(id); 192 | } else { 193 | this.users.fire('authenticate', payload, async (err, userId) => { 194 | if (err) { 195 | callback(err); 196 | socket.close(); 197 | } else { 198 | await this.redis.HSET('_route:user', userId, this.id); 199 | connectUser(userId); 200 | } 201 | }); 202 | } 203 | }); 204 | }); 205 | 206 | performance.addCpuLoad(this); 207 | performance.addMemoryUsage(this); 208 | 209 | console.info(`PlayNetwork started in ${Date.now() - startTime} ms`); 210 | } 211 | 212 | async connectRedis(url) { 213 | this.redis = createClient({ url }); 214 | this.redisSubscriber = this.redis.duplicate(); 215 | 216 | try { 217 | await this.redis.connect(); 218 | await this.redisSubscriber.connect(); 219 | } catch (err) { 220 | throw new Error(`Failed to connect to Redis at ${url}, ${err.code}. Ensure it is installed and/or is reachable across the network.`); 221 | } 222 | 223 | this.redisSubscriber.SUBSCRIBE('_destroy:user', async (id) => { 224 | const user = await this.users.get(id); 225 | if (!user || !user.serverId) return; 226 | await user.destroy(); 227 | }); 228 | 229 | console.info('Connected to Redis on ' + url); 230 | } 231 | 232 | async generateId(type, value) { 233 | const id = await this.redis.INCR('_id:' + type); 234 | if (!value) value = this.id; 235 | 236 | await this.redis.HSET(`_route:${type}`, id, value); 237 | return id; 238 | } 239 | 240 | async downloadAsset(saveTo, id, token) { 241 | const start = Date.now(); 242 | if (await downloadAsset(saveTo, id, token)) { 243 | console.info(`Asset downloaded ${id} in ${Date.now() - start} ms`); 244 | }; 245 | } 246 | 247 | async updateAssets(directory, token) { 248 | const start = Date.now(); 249 | if (await updateAssets(directory, token)) { 250 | console.info(`Assets updated in ${Date.now() - start} ms`); 251 | } 252 | } 253 | 254 | async _onMessage(msg, sender, callback) { 255 | if (this._reservedEvents.includes(msg.name)) return callback(new Error(`Event ${msg.name} is reserved`)); 256 | 257 | if (this.hasEvent(msg.name)) { 258 | this.fire(msg.name, sender, msg.data, callback); 259 | return; 260 | } 261 | 262 | let target = null; 263 | 264 | switch (msg.scope?.type) { 265 | case 'server': 266 | target = this; 267 | break; 268 | case 'user': 269 | target = await this.users.get(msg.scope.id); 270 | break; 271 | case 'room': 272 | target = this.rooms.get(msg.scope.id); 273 | break; 274 | case 'networkEntity': 275 | target = this.networkEntities.get(msg.scope.id); 276 | break; 277 | } 278 | 279 | if (!target) { 280 | const serverId = parseInt(await this.redis.HGET(`_route:${msg.scope?.type}`, msg.scope.id.toString())); 281 | if (!serverId) return; 282 | this.servers.get(serverId, (server) => { 283 | if (!msg.data) msg.data = {}; 284 | msg.data._origCallbackId = msg.id; 285 | server.send(msg.name, msg.data, msg.scope?.type, msg.scope?.id, sender.id, callback); 286 | }); 287 | return; 288 | }; 289 | 290 | target.fire(msg.name, sender, msg.data, callback); 291 | } 292 | 293 | _validateSettings(settings) { 294 | let error = ''; 295 | 296 | if (!settings) throw new Error('settings is required'); 297 | 298 | if (!settings.redisUrl) 299 | error += 'settings.redisUrl is required\n'; 300 | 301 | if (!settings.scriptsPath) 302 | error += 'settings.scriptsPath is required\n'; 303 | 304 | if (!settings.templatesPath) 305 | error += 'settings.templatesPath is required\n'; 306 | 307 | if (!settings.levelProvider) 308 | error += 'settings.levelProvider is required\n'; 309 | 310 | if (!settings.server || (!(settings.server instanceof http.Server) && !(settings.server instanceof https.Server))) 311 | error += 'settings.server is required\n'; 312 | 313 | if (error) throw new Error(error); 314 | } 315 | 316 | async destroy() { 317 | if (this.destroyed) return; 318 | this.destroyed = true; 319 | 320 | await this.redis.HDEL('_route:server', this.id.toString()); 321 | this.fire('destroy'); 322 | this.off(); 323 | 324 | console.info('PlayNetwork stopped'); 325 | 326 | process.exit(0); 327 | } 328 | } 329 | 330 | export default new PlayNetwork(); 331 | -------------------------------------------------------------------------------- /dist/network-entity.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @class NetworkEntity 3 | * @classdesc NetworkEntity is a {@link pc.ScriptType}, which is attached to a 4 | * {@link pc.ScriptComponent} of an {@link pc.Entity} that needs to be 5 | * synchronised between server and clients. It has unique ID, optional owner and 6 | * list of properties to be synchronised. For convenience, {@link pc.Entity} has 7 | * additional property: `entity.networkEntity`. 8 | * @extends pc.ScriptType 9 | * @property {string} id Unique identifier. 10 | * @property {boolean} mine Whether this entity is related to our User. 11 | * @property {Object[]} properties List of properties, which should be 12 | * synchronised and optionally can be interpolated. Each property `object` has 13 | * these properties: 14 | * 15 | * 16 | * | Param | Type | Description | 17 | * | --- | --- | --- | 18 | * | path | `string` | Path to a property. | 19 | * | interpolate | `boolean` | If value is type of: `number` | `Vec2` | `Vec3` | `Vec4` | `Quat` | `Color`, then it can be interpolated. | 20 | * | ignoreForOwner | `boolean` | If `true` then server will not send this property updates to an owner. | 21 | */ 22 | 23 | /** 24 | * @event NetworkEntity#* 25 | * @description {@link NetworkEntity} will receive own named network messages. 26 | * @param {object|array|string|number|boolean} [data] Message data. 27 | */ 28 | 29 | var NetworkEntity = pc.createScript('networkEntity'); 30 | NetworkEntity.attributes.add('id', { 31 | title: 'Network ID', 32 | type: 'string', 33 | description: 'Read-only. Network ID which is set by server' 34 | }); 35 | NetworkEntity.attributes.add('owner', { 36 | title: 'Owner', 37 | type: 'string', 38 | description: 'Read-only. User ID who is controlling an entity' 39 | }); 40 | NetworkEntity.attributes.add('properties', { 41 | title: 'Properties', 42 | type: 'json', 43 | array: true, 44 | description: 'List of property paths to be synchronised', 45 | schema: [{ 46 | type: 'string', 47 | name: 'path' 48 | }, { 49 | type: 'boolean', 50 | name: 'interpolate' 51 | }, { 52 | type: 'boolean', 53 | name: 'ignoreForOwner' 54 | }] 55 | }); 56 | NetworkEntity.prototype.initialize = function () { 57 | this.entity.networkEntity = this; 58 | this.user = pn.room.users.get(this.owner); 59 | this.mine = this.user?.mine; 60 | this.interpolations = new Map(); 61 | this._pathParts = {}; 62 | this.tmpObjects = new Map(); 63 | this.tmpObjects.set(pc.Vec2, new pc.Vec2()); 64 | this.tmpObjects.set(pc.Vec3, new pc.Vec3()); 65 | this.tmpObjects.set(pc.Vec4, new pc.Vec4()); 66 | this.tmpObjects.set(pc.Quat, new pc.Quat()); 67 | this.tmpObjects.set(pc.Color, new pc.Color()); 68 | this.rules = { 69 | 'parent': { 70 | get: state => { 71 | return state.parent; 72 | }, 73 | set: state => { 74 | const parentEntity = this.app.root.findByGuid(state.parent); // TODO: performance? 75 | 76 | if (!parentEntity) return; 77 | this.entity.reparent(parentEntity); 78 | } 79 | }, 80 | 'localPosition': { 81 | get: state => { 82 | const tmpObject = this.tmpObjects.get(pc.Vec3); 83 | this.parsers.get(pc.Vec3)(tmpObject, state.localPosition); 84 | return tmpObject; 85 | }, 86 | set: state => { 87 | const data = state.localPosition; 88 | this.entity.setLocalPosition(data.x, data.y, data.z); 89 | if (this.entity.rigidbody) { 90 | const position = this.entity.getPosition(); 91 | const rotation = this.entity.getEulerAngles(); 92 | this.entity.rigidbody.teleport(position, rotation); 93 | } 94 | } 95 | }, 96 | 'localRotation': { 97 | get: state => { 98 | const tmpObject = this.tmpObjects.get(pc.Quat); 99 | this.parsers.get(pc.Quat)(tmpObject, state.localRotation); 100 | return tmpObject; 101 | }, 102 | set: state => { 103 | const data = state.localRotation; 104 | this.entity.setLocalRotation(data.x, data.y, data.z, data.w); 105 | if (this.entity.rigidbody) { 106 | const position = this.entity.getPosition(); 107 | const rotation = this.entity.getEulerAngles(); 108 | this.entity.rigidbody.teleport(position.x, position.y, position.z, rotation.x, rotation.y, rotation.z); 109 | } 110 | } 111 | }, 112 | 'position': { 113 | get: state => { 114 | const tmpObject = this.tmpObjects.get(pc.Vec3); 115 | this.parsers.get(pc.Vec3)(tmpObject, state.position); 116 | return tmpObject; 117 | }, 118 | set: state => { 119 | const data = state.position; 120 | if (this.entity.rigidbody) { 121 | const rotation = this.entity.getEulerAngles(); 122 | this.entity.rigidbody.teleport(data.x, data.y, data.z, rotation.x, rotation.y, rotation.z); 123 | } else { 124 | this.entity.setPosition(data.x, data.y, data.z); 125 | } 126 | } 127 | }, 128 | 'rotation': { 129 | get: state => { 130 | const tmpObject = this.tmpObjects.get(pc.Quat); 131 | this.parsers.get(pc.Quat)(tmpObject, state.rotation); 132 | return tmpObject; 133 | }, 134 | set: state => { 135 | const data = state.rotation; 136 | this.entity.setRotation(data.x, data.y, data.z, data.w); 137 | if (this.entity.rigidbody) { 138 | const position = this.entity.getPosition(); 139 | const rotation = this.entity.getEulerAngles(); 140 | this.entity.rigidbody.teleport(position.x, position.y, position.z, rotation.x, rotation.y, rotation.z); 141 | } 142 | } 143 | }, 144 | 'scale': { 145 | get: state => { 146 | const tmpObject = this.tmpObjects.get(pc.Vec3); 147 | this.parsers.get(pc.Vec3)(tmpObject, state.scale); 148 | return tmpObject; 149 | }, 150 | set: state => { 151 | const data = state.scale; 152 | this.entity.setLocalScale(data.x, data.y, data.z); 153 | } 154 | } 155 | }; 156 | this.rulesInterpolate = { 157 | // TODO: add interpolation for localPosition/localRotation 158 | 'position': { 159 | get: () => { 160 | return this.entity.getPosition(); 161 | }, 162 | set: value => { 163 | this.entity.setPosition(value); 164 | if (this.entity.rigidbody) { 165 | this.entity.rigidbody.teleport(value.x, value.y, value.z); 166 | } 167 | } 168 | }, 169 | 'rotation': { 170 | get: () => { 171 | return this.entity.getRotation(); 172 | }, 173 | set: value => { 174 | this.entity.setRotation(value); 175 | if (this.entity.rigidbody) { 176 | let position = this.entity.getPosition(); 177 | let rotation = this.entity.getEulerAngles(); 178 | this.entity.rigidbody.teleport(position.x, position.y, position.z, rotation.x, rotation.y, rotation.z); 179 | } 180 | } 181 | }, 182 | 'scale': { 183 | get: () => { 184 | return this.entity.getLocalScale(); 185 | }, 186 | set: value => { 187 | return this.entity.setLocalScale(value); 188 | } 189 | } 190 | }; 191 | this.rulesReAssignTypes = new Set(); 192 | this.rulesReAssignTypes.add(pc.Vec2); 193 | this.rulesReAssignTypes.add(pc.Vec3); 194 | this.rulesReAssignTypes.add(pc.Vec4); 195 | this.rulesReAssignTypes.add(pc.Quat); 196 | this.rulesReAssignTypes.add(pc.Color); 197 | this.parsers = new Map(); 198 | this.parsers.set(pc.Vec2, (value, data) => { 199 | return value.set(data.x, data.y); 200 | }); 201 | this.parsers.set(pc.Vec3, (value, data) => { 202 | return value.set(data.x, data.y, data.z); 203 | }); 204 | this.parsers.set(pc.Vec4, (value, data) => { 205 | return value.set(data.x, data.y, data.z, data.w); 206 | }); 207 | this.parsers.set(pc.Quat, (value, data) => { 208 | return value.set(data.x, data.y, data.z, data.w); 209 | }); 210 | this.parsers.set(pc.Color, (value, data) => { 211 | return value.set(data.r, data.g, data.b, data.a); 212 | }); 213 | this.parsers.set(Map, (value, data) => { 214 | value.clear(); 215 | for (let [k, v] of data) { 216 | value.set(k, v); 217 | } 218 | }); 219 | this.entity.room.fire('_networkEntities:add', this); 220 | }; 221 | NetworkEntity.prototype.postInitialize = function () { 222 | for (let i = 0; i < this.properties.length; i++) { 223 | if (!this.properties[i].interpolate) continue; 224 | const path = this.properties[i].path; 225 | const parts = this._makePathParts(path); 226 | const rule = this.rulesInterpolate[path]; 227 | let node = this.entity; 228 | for (let p = 0; p < parts.length; p++) { 229 | let part = parts[p]; 230 | if (p === parts.length - 1) { 231 | let value; 232 | let setter; 233 | if (rule) { 234 | value = rule.get(); 235 | setter = rule.set; 236 | } else { 237 | value = node[part]; 238 | } 239 | this.interpolations.set(path, new InterpolateValue(value, node, part, setter, this.entity.room.tickrate)); 240 | } else { 241 | node = node[part]; 242 | } 243 | } 244 | } 245 | }; 246 | NetworkEntity.prototype.swap = function (old) { 247 | this.mine = old.mine; 248 | this._pathParts = old._pathParts; 249 | this.tmpObjects = old.tmpObjects; 250 | this.rules = old.rules; 251 | this.rulesReAssignTypes = old.rulesReAssignTypes; 252 | this.rulesInterpolate = old.rulesInterpolate; 253 | this.parsers = old.parsers; 254 | this.interpolations = old.interpolations; 255 | }; 256 | NetworkEntity.prototype.setState = function (state) { 257 | for (let i = 0; i < this.properties.length; i++) { 258 | if (this.mine && this.properties[i].ignoreForOwner) continue; 259 | const path = this.properties[i].path; 260 | const parts = this._makePathParts(path); 261 | const rule = this.rules[path]; 262 | const interpolator = this.interpolations.get(path); 263 | if (rule && state[path] !== undefined) { 264 | if (interpolator) { 265 | const interpolatorRule = this.rulesInterpolate[path]; 266 | if (interpolatorRule) { 267 | interpolator.add(rule.get(state)); 268 | continue; 269 | } 270 | } 271 | rule.set(state); 272 | continue; 273 | } 274 | let node = this.entity; 275 | let stateNode = state; 276 | for (let p = 0; p < parts.length; p++) { 277 | let part = parts[p]; 278 | if (stateNode[part] === undefined) continue; 279 | if (p === parts.length - 1) { 280 | if (node[part] && typeof node[part] === 'object') { 281 | const parser = this.parsers.get(node[part].constructor); 282 | if (parser) { 283 | if (interpolator) { 284 | const tmpObject = this.tmpObjects.get(node[part].constructor); 285 | parser(tmpObject, stateNode[part]); 286 | interpolator.add(tmpObject); 287 | } else { 288 | if (this.rulesReAssignTypes.has(node[part].constructor)) { 289 | node[part] = parser(node[part], stateNode[part]); 290 | } else { 291 | parser(node[part], stateNode[part]); 292 | } 293 | } 294 | } else if (node[part].constructor === Object) { 295 | node[part] = stateNode[part]; 296 | } else { 297 | // unknown property type, cannot set 298 | continue; 299 | } 300 | } else { 301 | node[part] = stateNode[part]; 302 | } 303 | } else { 304 | if (!node[part] || typeof node[part] === 'function') continue; 305 | node = node[part]; 306 | stateNode = stateNode[part]; 307 | } 308 | } 309 | } 310 | }; 311 | 312 | /** 313 | * @method send 314 | * @description Send named message to a server related to this NetworkEntity. 315 | * @param {string} name Name of a message. 316 | * @param {object|array|string|number|boolean} [data] JSON friendly message data. 317 | * @param {responseCallback} [callback] Callback that will be fired when response message is received. 318 | */ 319 | NetworkEntity.prototype.send = function (name, data, callback) { 320 | pn._send(name, data, 'networkEntity', this.id, callback); 321 | }; 322 | NetworkEntity.prototype._makePathParts = function (path) { 323 | let parts = this._pathParts[path]; 324 | if (!parts) { 325 | parts = path.split('.'); 326 | this._pathParts[path] = parts; 327 | } 328 | return parts; 329 | }; 330 | NetworkEntity.prototype.update = function (dt) { 331 | for (const interpolator of this.interpolations.values()) { 332 | interpolator.update(dt); 333 | } 334 | }; 335 | --------------------------------------------------------------------------------