mirror of
https://github.com/node-red/node-red.git
synced 2025-03-01 10:36:34 +00:00
Initial multiplayer feature
This commit is contained in:
parent
068b93befa
commit
014f206e9c
@ -143,6 +143,7 @@ module.exports = function(grunt) {
|
||||
"packages/node_modules/@node-red/editor-client/src/js/user.js",
|
||||
"packages/node_modules/@node-red/editor-client/src/js/comms.js",
|
||||
"packages/node_modules/@node-red/editor-client/src/js/runtime.js",
|
||||
"packages/node_modules/@node-red/editor-client/src/js/multiplayer.js",
|
||||
"packages/node_modules/@node-red/editor-client/src/js/text/bidi.js",
|
||||
"packages/node_modules/@node-red/editor-client/src/js/text/format.js",
|
||||
"packages/node_modules/@node-red/editor-client/src/js/ui/state.js",
|
||||
|
@ -233,6 +233,10 @@ module.exports = {
|
||||
themeSettings.projects = theme.projects;
|
||||
}
|
||||
|
||||
if (theme.hasOwnProperty("multiplayer")) {
|
||||
themeSettings.multiplayer = theme.multiplayer;
|
||||
}
|
||||
|
||||
if (theme.hasOwnProperty("keymap")) {
|
||||
themeSettings.keymap = theme.keymap;
|
||||
}
|
||||
|
217
packages/node_modules/@node-red/editor-client/src/js/multiplayer.js
vendored
Normal file
217
packages/node_modules/@node-red/editor-client/src/js/multiplayer.js
vendored
Normal file
@ -0,0 +1,217 @@
|
||||
RED.multiplayer = (function () {
|
||||
|
||||
// sessionId - used to identify sessions across websocket reconnects
|
||||
let sessionId
|
||||
|
||||
let headerWidget
|
||||
// Map of session id to { session:'', user:{}, location:{}}
|
||||
let connections = {}
|
||||
// Map of username to { user:{}, connections:[] }
|
||||
let users = {}
|
||||
|
||||
function addUserConnection (connection) {
|
||||
if (connections[connection.session]) {
|
||||
// This is an existing connection that has been authenticated
|
||||
const existingConnection = connections[connection.session]
|
||||
if (existingConnection.user.username !== connection.user.username) {
|
||||
removeUserButton(users[existingConnection.user.username])
|
||||
}
|
||||
}
|
||||
connections[connection.session] = connection
|
||||
const user = users[connection.user.username] = users[connection.user.username] || {
|
||||
user: connection.user,
|
||||
connections: []
|
||||
}
|
||||
connection.location = connection.location || {}
|
||||
user.connections.push(connection)
|
||||
|
||||
if (connection.user.username === RED.settings.user?.username ||
|
||||
connection.session === sessionId
|
||||
) {
|
||||
// This is the current user - do not add a extra button for them
|
||||
} else {
|
||||
if (user.connections.length === 1) {
|
||||
if (user.button) {
|
||||
clearTimeout(user.inactiveTimeout)
|
||||
clearTimeout(user.removeTimeout)
|
||||
user.button.removeClass('inactive')
|
||||
} else {
|
||||
addUserButton(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeUserConnection (session, isDisconnected) {
|
||||
const connection = connections[session]
|
||||
delete connections[session]
|
||||
const user = users[connection.user.username]
|
||||
const i = user.connections.indexOf(connection)
|
||||
user.connections.splice(i, 1)
|
||||
if (isDisconnected) {
|
||||
removeUserButton(user)
|
||||
} else {
|
||||
if (user.connections.length === 0) {
|
||||
// Give the user 5s to reconnect before marking inactive
|
||||
user.inactiveTimeout = setTimeout(() => {
|
||||
user.button.addClass('inactive')
|
||||
// Give the user further 20 seconds to reconnect before removing them
|
||||
// from the user toolbar entirely
|
||||
user.removeTimeout = setTimeout(() => {
|
||||
removeUserButton(user)
|
||||
}, 20000)
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addUserButton (user) {
|
||||
user.button = $('<li class="red-ui-multiplayer-user"><button type="button" class="red-ui-multiplayer-user-icon" href="#"></button></li>')
|
||||
.attr('data-username', user.user.username)
|
||||
.prependTo("#red-ui-multiplayer-user-list");
|
||||
var button = user.button.find("button")
|
||||
button.on('click', function () {
|
||||
RED.popover.create({
|
||||
target:button,
|
||||
trigger: 'modal',
|
||||
interactive: true,
|
||||
width: "250px",
|
||||
direction: 'bottom',
|
||||
content: () => {
|
||||
const content = $('<div>')
|
||||
$('<div style="text-align: center">').text(user.user.username).appendTo(content)
|
||||
|
||||
const location = user.connections[0].location
|
||||
if (location.workspace) {
|
||||
const ws = RED.nodes.workspace(location.workspace) || RED.nodes.subflow(location.workspace)
|
||||
if (ws) {
|
||||
$('<div>').text(`${ws.type}: ${ws.label||ws.name||ws.id}`).appendTo(content)
|
||||
} else {
|
||||
$('<div>').text(`tab: unknown`).appendTo(content)
|
||||
}
|
||||
}
|
||||
if (location.node) {
|
||||
const node = RED.nodes.node(location.node)
|
||||
if (node) {
|
||||
$('<div>').text(`node: ${node.id}`).appendTo(content)
|
||||
} else {
|
||||
$('<div>').text(`node: unknown`).appendTo(content)
|
||||
}
|
||||
}
|
||||
return content
|
||||
},
|
||||
}).open()
|
||||
})
|
||||
if (!user.user.image) {
|
||||
$('<i class="fa fa-user"></i>').appendTo(button);
|
||||
} else {
|
||||
$('<span class="user-profile"></span>').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 = $('<li><ul id="red-ui-multiplayer-user-list"></ul></li>').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)
|
||||
}
|
||||
}
|
||||
})();
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
}
|
||||
|
48
packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss
vendored
Normal file
48
packages/node_modules/@node-red/editor-client/src/sass/multiplayer.scss
vendored
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -73,3 +73,5 @@
|
||||
@import "radialMenu";
|
||||
|
||||
@import "tourGuide";
|
||||
|
||||
@import "multiplayer";
|
||||
|
@ -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) {
|
||||
|
119
packages/node_modules/@node-red/runtime/lib/multiplayer/index.js
vendored
Normal file
119
packages/node_modules/@node-red/runtime/lib/multiplayer/index.js
vendored
Normal file
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
4
packages/node_modules/node-red/settings.js
vendored
4
packages/node_modules/node-red/settings.js
vendored
@ -437,6 +437,10 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
multiplayer: {
|
||||
/** To enable the Multiplayer feature, set this value to true */
|
||||
enabled: false
|
||||
},
|
||||
},
|
||||
|
||||
/*******************************************************************************
|
||||
|
Loading…
x
Reference in New Issue
Block a user