').text(`node: ${node.id}`).appendTo(content)
+ } else {
+ $('
').text(`node: unknown`).appendTo(content)
+ }
+ }
+ return content
+ },
+ }).open()
+ })
+ if (!user.user.image) {
+ $('
').appendTo(button);
+ } else {
+ $('
').css({
+ backgroundImage: "url("+user.user.image+")",
+ }).appendTo(button);
+ }
+ }
+
+ function getLocation () {
+ const location = {
+ workspace: RED.workspaces.active()
+ }
+ const editStack = RED.editor.getEditStack()
+ for (let i = editStack.length - 1; i >= 0; i--) {
+ if (editStack[i].id) {
+ location.node = editStack[i].id
+ break
+ }
+ }
+ return location
+ }
+ function updateLocation () {
+ const location = getLocation()
+ if (location.workspace !== 0) {
+ log('send', 'multiplayer/location', location)
+ RED.comms.send('multiplayer/location', location)
+ }
+ }
+
+ function removeUserButton (user) {
+ user.button.remove()
+ delete user.button
+ }
+
+ function updateUserLocation (data) {
+ connections[data.session].location = data
+ delete data.session
+ }
+ return {
+ init: function () {
+
+
+ sessionId = RED.settings.getLocal('multiplayer:sessionId')
+ if (!sessionId) {
+ sessionId = RED.nodes.id()
+ RED.settings.setLocal('multiplayer:sessionId', sessionId)
+ }
+
+ headerWidget = $('
').prependTo('.red-ui-header-toolbar')
+
+ RED.comms.on('connect', () => {
+ const location = getLocation()
+ const connectInfo = {
+ session: sessionId
+ }
+ if (location.workspace !== 0) {
+ connectInfo.location = location
+ }
+ RED.comms.send('multiplayer/connect', connectInfo)
+ })
+ RED.comms.subscribe('multiplayer/#', (topic, msg) => {
+ log('recv', topic, msg)
+ if (topic === 'multiplayer/init') {
+ // We have just reconnected, runtime has sent state to
+ // initialise the world
+ connections = {}
+ users = {}
+ $('#red-ui-multiplayer-user-list').empty()
+
+ msg.forEach(connection => {
+ addUserConnection(connection)
+ })
+ } else if (topic === 'multiplayer/connection-added') {
+ addUserConnection(msg)
+ } else if (topic === 'multiplayer/connection-removed') {
+ removeUserConnection(msg.session, msg.disconnected)
+ } else if (topic === 'multiplayer/location') {
+ updateUserLocation(msg)
+ }
+ })
+
+ RED.events.on('workspace:change', (event) => {
+ updateLocation()
+ })
+ RED.events.on('editor:open', () => {
+ updateLocation()
+ })
+ RED.events.on('editor:close', () => {
+ updateLocation()
+ })
+ RED.events.on('editor:change', () => {
+ updateLocation()
+ })
+ RED.events.on('login', () => {
+ updateLocation()
+ })
+ RED.events.on('logout', () => {
+ const disconnectInfo = {
+ session: sessionId
+ }
+ RED.comms.send('multiplayer/disconnect', disconnectInfo)
+ RED.settings.removeLocal('multiplayer:sessionId')
+ })
+ }
+ }
+
+ function log() {
+ if (RED.multiplayer.DEBUG) {
+ console.log('[multiplayer]', ...arguments)
+ }
+ }
+})();
diff --git a/packages/node_modules/@node-red/editor-client/src/js/red.js b/packages/node_modules/@node-red/editor-client/src/js/red.js
index d13d7ca24..5bdd5af7d 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/red.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/red.js
@@ -803,6 +803,10 @@ var RED = (function() {
RED.nodes.init();
RED.runtime.init()
+
+ if (RED.settings.theme("multiplayer.enabled",false)) {
+ RED.multiplayer.init()
+ }
RED.comms.connect();
$("#red-ui-main-container").show();
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js
index 9ddd3d866..1a70839ae 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/common/popover.js
@@ -211,7 +211,7 @@ RED.popover = (function() {
closePopup(true);
});
}
- if (trigger === 'hover' && options.interactive) {
+ if (/*trigger === 'hover' && */options.interactive) {
div.on('mouseenter', function(e) {
clearTimeout(timer);
active = true;
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js b/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js
index 0ea6d6044..d428d8a00 100644
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/tray.js
@@ -264,6 +264,7 @@
setTimeout(function() {
oldTray.tray.detach();
showTray(options);
+ RED.events.emit('editor:change')
},250)
} else {
if (stack.length > 0) {
@@ -333,6 +334,7 @@
RED.view.focus();
} else {
stack[stack.length-1].tray.css("z-index", "auto");
+ RED.events.emit('editor:change')
}
},250)
}
diff --git a/packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss b/packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss
new file mode 100644
index 000000000..58fb9472f
--- /dev/null
+++ b/packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss
@@ -0,0 +1,48 @@
+#red-ui-multiplayer-user-list {
+ display: inline-flex;
+ align-items: center;
+ margin: 0 5px;
+ li {
+ display: inline-flex;
+ align-items: center;
+ width: 30px;
+ margin: 0 2px;
+ }
+
+}
+
+.red-ui-multiplayer-user-icon {
+ background: var(--red-ui-header-background);
+ border: 2px solid var(--red-ui-header-menu-color);
+ border-radius: 30px;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: 28px;
+ height: 28px;
+ text-align: center;
+ overflow: hidden;
+ box-sizing: border-box;
+ text-decoration: none;
+ color: var(--red-ui-header-menu-color);
+ padding: 0px;
+ margin: 0px;
+ vertical-align: middle;
+
+ &:focus {
+ outline: none;
+ }
+
+ .red-ui-multiplayer-user.inactive & {
+ opacity: 0.5;
+ }
+ .user-profile {
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: contain;
+ display: inline-block;
+ vertical-align: middle;
+ width: 28px;
+ height: 28px;
+ }
+}
\ No newline at end of file
diff --git a/packages/node_modules/@node-red/editor-client/src/sass/style.scss b/packages/node_modules/@node-red/editor-client/src/sass/style.scss
index 412290f78..77148abde 100644
--- a/packages/node_modules/@node-red/editor-client/src/sass/style.scss
+++ b/packages/node_modules/@node-red/editor-client/src/sass/style.scss
@@ -73,3 +73,5 @@
@import "radialMenu";
@import "tourGuide";
+
+@import "multiplayer";
diff --git a/packages/node_modules/@node-red/runtime/lib/index.js b/packages/node_modules/@node-red/runtime/lib/index.js
index 7fc9af041..4ac7cfb5b 100644
--- a/packages/node_modules/@node-red/runtime/lib/index.js
+++ b/packages/node_modules/@node-red/runtime/lib/index.js
@@ -22,6 +22,7 @@ var storage = require("./storage");
var library = require("./library");
var plugins = require("./plugins");
var settings = require("./settings");
+const multiplayer = require("./multiplayer");
var express = require("express");
var path = require('path');
@@ -135,6 +136,7 @@ function start() {
.then(function() { return storage.init(runtime)})
.then(function() { return settings.load(storage)})
.then(function() { return library.init(runtime)})
+ .then(function() { return multiplayer.init(runtime)})
.then(function() {
if (settings.available()) {
if (settings.get('instanceId') === undefined) {
diff --git a/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js b/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js
new file mode 100644
index 000000000..a4108e51f
--- /dev/null
+++ b/packages/node_modules/@node-red/runtime/lib/multiplayer/index.js
@@ -0,0 +1,119 @@
+let runtime
+
+/**
+ * Active sessions, mapped by multiplayer session ids
+ */
+const sessions = new Map()
+
+/**
+ * Active connections, mapping comms session to multiplayer session
+ */
+const connections = new Map()
+
+
+function getSessionsList() {
+ return Array.from(sessions.values()).filter(session => session.active)
+}
+
+module.exports = {
+ init: function(_runtime) {
+ runtime = _runtime
+ runtime.events.on('comms:connection-removed', (opts) => {
+ const existingSessionId = connections.get(opts.session)
+ if (existingSessionId) {
+ connections.delete(opts.session)
+ const session = sessions.get(existingSessionId)
+ session.active = false
+ session.idleTimeout = setTimeout(() => {
+ sessions.delete(existingSessionId)
+ }, 30000)
+ runtime.events.emit('comms', {
+ topic: "multiplayer/connection-removed",
+ data: { session: existingSessionId }
+ })
+ }
+ })
+ runtime.events.on('comms:message:multiplayer/connect', (opts) => {
+ let session
+ if (!sessions.has(opts.data.session)) {
+ // Brand new session
+ let user = opts.user
+ if (!user || user.anonymous) {
+ user = user || { anonymous: true }
+ user.username = `Anon ${Math.floor(Math.random()*100)}`
+ }
+ session = {
+ session: opts.data.session,
+ user,
+ active: true
+ }
+ sessions.set(opts.data.session, session)
+ connections.set(opts.session, opts.data.session)
+ runtime.log.trace(`multiplayer new session:${opts.data.session} user:${user.username}`)
+ } else {
+ // Reconnected connection - keep existing state
+ connections.set(opts.session, opts.data.session)
+ // const existingConnection = connections.get(opts.data.session)
+ session = sessions.get(opts.data.session)
+ session.active = true
+ runtime.log.trace(`multiplayer reconnected session:${opts.data.session} user:${session.user.username}`)
+ clearTimeout(session.idleTimeout)
+ }
+ // Tell existing sessions about the new connection
+ runtime.events.emit('comms', {
+ topic: "multiplayer/connection-added",
+ excludeSession: opts.session,
+ data: session
+ })
+
+ // Send init info to new connection
+ const initPacket = {
+ topic: "multiplayer/init",
+ data: getSessionsList(),
+ session: opts.session
+ }
+ // console.log('<<', initPacket)
+ runtime.events.emit('comms', initPacket)
+ })
+ runtime.events.on('comms:message:multiplayer/disconnect', (opts) => {
+ const existingSessionId = connections.get(opts.session)
+ connections.delete(opts.session)
+ sessions.delete(existingSessionId)
+
+ runtime.events.emit('comms', {
+ topic: "multiplayer/connection-removed",
+ data: { session: existingSessionId, disconnected: true }
+ })
+ })
+ runtime.events.on('comms:message:multiplayer/location', (opts) => {
+ // console.log('>>>', opts.user, opts.data)
+
+ const sessionId = connections.get(opts.session)
+ const session = sessions.get(sessionId)
+
+ if (opts.user) {
+ if (session.user.anonymous !== opts.user.anonymous) {
+ session.user = opts.user
+ runtime.events.emit('comms', {
+ topic: 'multiplayer/connection-added',
+ excludeSession: opts.session,
+ data: session
+ })
+ }
+ }
+
+ session.location = opts.data
+
+ const payload = {
+ session: sessionId,
+ workspace: opts.data.workspace,
+ node: opts.data.node
+ }
+ runtime.events.emit('comms', {
+ topic: 'multiplayer/location',
+ data: payload,
+ excludeSession: opts.session
+ })
+ })
+ }
+}
\ No newline at end of file
diff --git a/packages/node_modules/node-red/settings.js b/packages/node_modules/node-red/settings.js
index 864707538..cb7b58795 100644
--- a/packages/node_modules/node-red/settings.js
+++ b/packages/node_modules/node-red/settings.js
@@ -437,6 +437,10 @@ module.exports = {
}
},
+ multiplayer: {
+ /** To enable the Multiplayer feature, set this value to true */
+ enabled: false
+ },
},
/*******************************************************************************