mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Improve lobbies connections (#6762)
* Lobby start is now more reliable, and wait for everyone to be connected to the host to start the game, rather than wait on a fixed countdown. This can speed up the start (as well as slow it down on slower connections.) * A timeout is still in place to start the game without the missing players if they couldn't connect * Lobbies can now be joined after the game is started, if defined as such in the lobbies section of the game dashboard * new actions & conditions are available * Slightly improve disconnection time in preview
This commit is contained in:
@@ -417,7 +417,7 @@ module.exports = {
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName('gdjs.multiplayerMessageManager.hasAnyPlayerLeft');
|
||||
.setFunctionName('gdjs.multiplayerMessageManager.hasAnyPlayerJustLeft');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
@@ -443,7 +443,108 @@ module.exports = {
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName('gdjs.multiplayerMessageManager.hasPlayerLeft');
|
||||
.setFunctionName('gdjs.multiplayerMessageManager.hasPlayerJustLeft');
|
||||
|
||||
extension
|
||||
.addExpression(
|
||||
'LastLeftPlayerNumber',
|
||||
_('Last left player number'),
|
||||
_('Returns the number of the player that has just left the lobby.'),
|
||||
_('Lobbies'),
|
||||
'JsPlatform/Extensions/multiplayer.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Multiplayer/peer.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName(
|
||||
'gdjs.multiplayerMessageManager.getLatestPlayerWhoJustLeft'
|
||||
);
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasAnyPlayerJoined',
|
||||
_('Any player has joined'),
|
||||
_('Check if any player has joined the lobby.'),
|
||||
_('Any player has joined'),
|
||||
_('Lobbies'),
|
||||
'JsPlatform/Extensions/multiplayer.svg',
|
||||
'JsPlatform/Extensions/multiplayer.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Multiplayer/peer.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName('gdjs.multiplayerMessageManager.hasAnyPlayerJustJoined');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasPlayerJoined',
|
||||
_('Player has joined'),
|
||||
_('Check if the player has joined the lobby.'),
|
||||
_('Player _PARAM0_ has joined'),
|
||||
_('Lobbies'),
|
||||
'JsPlatform/Extensions/multiplayer.svg',
|
||||
'JsPlatform/Extensions/multiplayer.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.addParameter('number', _('Player number'), '', false)
|
||||
.setIncludeFile('Extensions/Multiplayer/peer.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName('gdjs.multiplayerMessageManager.hasPlayerJustJoined');
|
||||
|
||||
extension
|
||||
.addExpression(
|
||||
'LastJoinedPlayerNumber',
|
||||
_('Last joined player number'),
|
||||
_('Returns the number of the player that has just joined the lobby.'),
|
||||
_('Lobbies'),
|
||||
'JsPlatform/Extensions/multiplayer.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Multiplayer/peer.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/peerJsHelper.js')
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayercomponents.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/messageManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayerVariablesManager.js')
|
||||
.addIncludeFile('Extensions/Multiplayer/multiplayertools.js')
|
||||
.setFunctionName(
|
||||
'gdjs.multiplayerMessageManager.getLatestPlayerWhoJustJoined'
|
||||
);
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
|
@@ -34,6 +34,30 @@ namespace gdjs {
|
||||
this.cache.add(key);
|
||||
this.keys.push(key);
|
||||
}
|
||||
|
||||
clear = () => {
|
||||
this.cache.clear();
|
||||
this.keys = [];
|
||||
};
|
||||
}
|
||||
|
||||
class SavedSyncDataUpdates<T> {
|
||||
private _updates: T[] = [];
|
||||
|
||||
store(update: T) {
|
||||
this._updates.push(update);
|
||||
if (this._updates.length > 10) {
|
||||
this._updates.shift();
|
||||
}
|
||||
}
|
||||
|
||||
getUpdates() {
|
||||
return this._updates;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._updates = [];
|
||||
}
|
||||
}
|
||||
|
||||
export type MultiplayerMessageManager = ReturnType<
|
||||
@@ -91,24 +115,39 @@ namespace gdjs {
|
||||
let lastSceneSyncTimestamp = 0;
|
||||
let lastSentSceneSyncData: LayoutNetworkSyncData | null = null;
|
||||
let numberOfForcedSceneUpdates = 0;
|
||||
let lastReceivedSceneSyncDataUpdates = new SavedSyncDataUpdates<
|
||||
LayoutNetworkSyncData
|
||||
>();
|
||||
|
||||
// The number of times per second the game data should be synchronized.
|
||||
const gameSyncDataTickRate = 1;
|
||||
let lastGameSyncTimestamp = 0;
|
||||
let lastSentGameSyncData: GameNetworkSyncData | null = null;
|
||||
let numberOfForcedGameUpdates = 0;
|
||||
let lastReceivedGameSyncDataUpdates = new SavedSyncDataUpdates<
|
||||
GameNetworkSyncData
|
||||
>();
|
||||
|
||||
// Send heartbeat messages to host to ensure the connection is still alive.
|
||||
// Send heartbeat messages from host to players, ensuring their connection is still alive,
|
||||
// measure the ping, and send other useful info.
|
||||
const heartbeatTickRate = 1;
|
||||
let lastHeartbeatTimestamp = 0;
|
||||
let _playersLastHeartbeatInfo: {
|
||||
[playerNumber: number]: {
|
||||
lastRoundTripTimes: number[];
|
||||
};
|
||||
let _playersLastRoundTripTimes: {
|
||||
[playerNumber: number]: number[];
|
||||
} = {};
|
||||
let _peerIdToPlayerNumber: { [peerId: string]: number } = {};
|
||||
let _playersPings: { [playerNumber: number]: number } = { 1: 0 };
|
||||
let _playersInfo: {
|
||||
[playerNumber: number]: {
|
||||
ping: number;
|
||||
playerId: string;
|
||||
username: string;
|
||||
};
|
||||
} = {};
|
||||
let _playerNumbersWhoJustLeft: number[] = [];
|
||||
let _playerNumbersWhoJustJoined: number[] = [];
|
||||
let _temporaryPlayerNumberToUsername: {
|
||||
[playerNumber: number]: string;
|
||||
} = {};
|
||||
|
||||
const addExpectedMessageAcknowledgement = ({
|
||||
originalMessageName,
|
||||
@@ -157,11 +196,6 @@ namespace gdjs {
|
||||
});
|
||||
};
|
||||
|
||||
const clearExpectedMessageAcknowledgements = () => {
|
||||
expectedMessageAcknowledgements = {};
|
||||
_lastClockReceivedByInstanceByScene = {};
|
||||
};
|
||||
|
||||
const getLastClockReceivedForInstanceOnScene = ({
|
||||
sceneNetworkId,
|
||||
instanceNetworkId,
|
||||
@@ -411,6 +445,12 @@ namespace gdjs {
|
||||
const handleChangeInstanceOwnerMessagesReceived = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Change owner messages do not need to be saved for later use, as the game will automatically change the owner of
|
||||
// the instance when receiving an update message with a different owner.
|
||||
return;
|
||||
}
|
||||
|
||||
const p2pMessagesMap = gdjs.multiplayerPeerJsHelper.getAllMessagesMap();
|
||||
const messageNamesArray = Array.from(p2pMessagesMap.keys());
|
||||
|
||||
@@ -563,6 +603,12 @@ namespace gdjs {
|
||||
const handleUpdateInstanceMessagesReceived = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Update instance messages do not need to be saved for later use, as the updates are sent pretty often,
|
||||
// a new one will be received very quickly.
|
||||
return;
|
||||
}
|
||||
|
||||
const p2pMessagesMap = gdjs.multiplayerPeerJsHelper.getAllMessagesMap();
|
||||
const messageNamesArray = Array.from(p2pMessagesMap.keys());
|
||||
|
||||
@@ -732,6 +778,12 @@ namespace gdjs {
|
||||
const handleChangeVariableOwnerMessagesReceived = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Change owner messages do not need to be saved for later use, as the game will automatically change the owner of
|
||||
// the variable when receiving an update message with a different owner.
|
||||
return;
|
||||
}
|
||||
|
||||
const p2pMessagesMap = gdjs.multiplayerPeerJsHelper.getAllMessagesMap();
|
||||
const messageNamesArray = Array.from(p2pMessagesMap.keys());
|
||||
|
||||
@@ -884,6 +936,12 @@ namespace gdjs {
|
||||
};
|
||||
|
||||
const handleAcknowledgeMessagesReceived = () => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Acknowledgment messages are mainly a response for ownership change, destruction, and custom messages,
|
||||
// which are not sent when the game is not ready.
|
||||
return;
|
||||
}
|
||||
|
||||
const p2pMessagesMap = gdjs.multiplayerPeerJsHelper.getAllMessagesMap();
|
||||
const messageNamesArray = Array.from(p2pMessagesMap.keys());
|
||||
// When we receive acknowledgement messages, save it in the extension, to avoid sending the message again.
|
||||
@@ -958,6 +1016,12 @@ namespace gdjs {
|
||||
const resendClearOrCancelAcknowledgedMessages = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Acknowledgment messages are mainly a response for ownership change, destruction, and custom messages,
|
||||
// which are not sent when the game is not ready.
|
||||
return;
|
||||
}
|
||||
|
||||
// When all acknowledgments are received for an message, we can clear the message from our
|
||||
// list of expected acknowledgments.
|
||||
const expectedMessageNames = Object.keys(expectedMessageAcknowledgements);
|
||||
@@ -1179,6 +1243,12 @@ namespace gdjs {
|
||||
const handleDestroyInstanceMessagesReceived = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Destroy messages do not need to be saved for later use, as the game will automatically destroy
|
||||
// the instance if it does not receive an update message from it. So we return early.
|
||||
return;
|
||||
}
|
||||
|
||||
const p2pMessagesMap = gdjs.multiplayerPeerJsHelper.getAllMessagesMap();
|
||||
const messageNamesArray = Array.from(p2pMessagesMap.keys());
|
||||
const destroyInstanceMessageNames = messageNamesArray.filter(
|
||||
@@ -1453,6 +1523,11 @@ namespace gdjs {
|
||||
};
|
||||
|
||||
const handleCustomMessagesReceived = (): void => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Assume that the custom messages are not worth saving for later use.
|
||||
return;
|
||||
}
|
||||
|
||||
const p2pMessagesMap = gdjs.multiplayerPeerJsHelper.getAllMessagesMap();
|
||||
const messageNamesArray = Array.from(p2pMessagesMap.keys());
|
||||
const customMessageNames = messageNamesArray.filter((messageName) =>
|
||||
@@ -1569,6 +1644,11 @@ namespace gdjs {
|
||||
const handleUpdateSceneMessagesToSend = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): void => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Don't send messages if the multiplayer is not ready.
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneNetworkSyncData = runtimeScene.getNetworkSyncData({
|
||||
playerNumber: gdjs.multiplayer.getCurrentPlayerNumber(),
|
||||
});
|
||||
@@ -1630,7 +1710,13 @@ namespace gdjs {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeScene.updateFromNetworkSyncData(messageData);
|
||||
if (gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
runtimeScene.updateFromNetworkSyncData(messageData);
|
||||
} else {
|
||||
// If the game is not ready to receive game update messages, we need to save the data for later use.
|
||||
// This can happen when joining a game that is already running.
|
||||
lastReceivedSceneSyncDataUpdates.store(messageData);
|
||||
}
|
||||
|
||||
// If we are are the host,
|
||||
// we need to relay the scene update to others except the player who sent the update message.
|
||||
@@ -1717,6 +1803,11 @@ namespace gdjs {
|
||||
const handleUpdateGameMessagesToSend = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): void => {
|
||||
if (!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
// Don't send messages if the multiplayer is not ready.
|
||||
return;
|
||||
}
|
||||
|
||||
const gameNetworkSyncData = runtimeScene.getGame().getNetworkSyncData({
|
||||
playerNumber: gdjs.multiplayer.getCurrentPlayerNumber(),
|
||||
});
|
||||
@@ -1768,7 +1859,13 @@ namespace gdjs {
|
||||
messages.forEach((message) => {
|
||||
const messageData = message.getData();
|
||||
const messageSender = message.getSender();
|
||||
runtimeScene.getGame().updateFromNetworkSyncData(messageData);
|
||||
if (gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()) {
|
||||
runtimeScene.getGame().updateFromNetworkSyncData(messageData);
|
||||
} else {
|
||||
// If the game is not ready to receive game update messages, we need to save the data for later use.
|
||||
// This can happen when joining a game that is already running.
|
||||
lastReceivedGameSyncDataUpdates.store(messageData);
|
||||
}
|
||||
|
||||
// If we are are the host,
|
||||
// we need to relay the game update to others except the player who sent the update message.
|
||||
@@ -1785,23 +1882,51 @@ namespace gdjs {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavedUpdateMessages = (runtimeScene: gdjs.RuntimeScene) => {
|
||||
// Reapply the game saved updates.
|
||||
lastReceivedGameSyncDataUpdates.getUpdates().forEach((messageData) => {
|
||||
runtimeScene.getGame().updateFromNetworkSyncData(messageData);
|
||||
});
|
||||
lastReceivedGameSyncDataUpdates.clear();
|
||||
|
||||
// Then reapply the scene saved updates.
|
||||
lastReceivedSceneSyncDataUpdates.getUpdates().forEach((messageData) => {
|
||||
const sceneNetworkId = messageData.id;
|
||||
|
||||
if (sceneNetworkId !== runtimeScene.networkId) {
|
||||
debugLogger.info(
|
||||
`Saved update of scene ${sceneNetworkId}, but we are on ${runtimeScene.networkId}. Skipping.`
|
||||
);
|
||||
// The scene is not the current scene.
|
||||
return;
|
||||
}
|
||||
});
|
||||
lastReceivedSceneSyncDataUpdates.clear();
|
||||
};
|
||||
|
||||
const heartbeatMessageNamePrefix = '#heartbeat';
|
||||
const heartbeastMessageRegex = /#heartbeat#(.+)/;
|
||||
const createHeartbeatMessage = (): {
|
||||
messageName: string;
|
||||
messageData: any;
|
||||
} => {
|
||||
const playersPings = {
|
||||
1: 0, // Player 1 is the host, so we don't need to compute the ping.
|
||||
// Ensure player 1 is correctly set when the first heartbeat is sent.
|
||||
_playersInfo[1] = {
|
||||
ping: 0, // Player 1 is the host, so we don't need to compute the ping.
|
||||
playerId: gdjs.playerAuthentication.getUserId(),
|
||||
username: gdjs.playerAuthentication.getUsername(),
|
||||
};
|
||||
for (const playerNumber in _playersLastHeartbeatInfo) {
|
||||
playersPings[playerNumber] = getPlayerPing(parseInt(playerNumber, 10));
|
||||
for (const playerNumber in _playersInfo) {
|
||||
_playersInfo[playerNumber] = {
|
||||
..._playersInfo[playerNumber],
|
||||
ping: getPlayerPing(parseInt(playerNumber, 10)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
messageName: `${heartbeatMessageNamePrefix}#${gdjs.multiplayer.getCurrentPlayerNumber()}`,
|
||||
messageData: {
|
||||
now: getTimeNow(), // we send the current time to compute the ping.
|
||||
playersPings,
|
||||
playersInfo: _playersInfo,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1817,11 +1942,16 @@ namespace gdjs {
|
||||
messageName: `${heartbeatMessageNamePrefix}#${gdjs.multiplayer.getCurrentPlayerNumber()}`,
|
||||
messageData: {
|
||||
sentAt: heartbeatSentAt,
|
||||
playerId: gdjs.playerAuthentication.getUserId(),
|
||||
username: gdjs.playerAuthentication.getUsername(),
|
||||
},
|
||||
};
|
||||
};
|
||||
const hasSentHeartbeatRecently = () => {
|
||||
return getTimeNow() - lastHeartbeatTimestamp < 1000 / heartbeatTickRate;
|
||||
return (
|
||||
!!lastHeartbeatTimestamp &&
|
||||
getTimeNow() - lastHeartbeatTimestamp < 1000 / heartbeatTickRate
|
||||
);
|
||||
};
|
||||
const handleHeartbeatsToSend = () => {
|
||||
// Only host sends heartbeats to all players regularly:
|
||||
@@ -1865,10 +1995,53 @@ namespace gdjs {
|
||||
// Ensure we know who is who.
|
||||
_peerIdToPlayerNumber[messageSender] = playerNumber;
|
||||
|
||||
// If we are not the host, save what the host told us about the pings and respond
|
||||
// with a heartbeat immediately.
|
||||
// If we are not the host, save what the host told us about the other players info
|
||||
// and respond with a heartbeat immediately, informing the host of our playerId and username.
|
||||
if (!gdjs.multiplayer.isPlayerHost()) {
|
||||
_playersPings = messageData.playersPings;
|
||||
const currentPlayerNumber = gdjs.multiplayer.getCurrentPlayerNumber();
|
||||
const currentlyKnownPlayerNumbers = Object.keys(
|
||||
_playersInfo
|
||||
).map((playerNumber) => parseInt(playerNumber, 10));
|
||||
const receivedPlayerNumbers = Object.keys(
|
||||
messageData.playersInfo
|
||||
).map((playerNumber) => parseInt(playerNumber, 10));
|
||||
const currentlyKnownPingForCurrentUser =
|
||||
_playersInfo[currentPlayerNumber] &&
|
||||
_playersInfo[currentPlayerNumber].ping;
|
||||
// If there are no players info yet, we're probably just connecting.
|
||||
// This can happen when joining a game that is already running.
|
||||
// Do not handle this case to avoid displaying too many notifications.
|
||||
if (!!currentlyKnownPlayerNumbers.length) {
|
||||
// Look at the players info received to know if there are new players who just connected.
|
||||
const newPlayerNumbers = receivedPlayerNumbers.filter(
|
||||
(playerNumber) =>
|
||||
!currentlyKnownPlayerNumbers.includes(playerNumber) &&
|
||||
playerNumber !== currentPlayerNumber // Do not consider ourselves as a new player.
|
||||
);
|
||||
_playerNumbersWhoJustJoined.push(...newPlayerNumbers);
|
||||
// Or players who have disconnected.
|
||||
const playerNumbersWhoHaveDisconnected = currentlyKnownPlayerNumbers.filter(
|
||||
(playerNumber) => !receivedPlayerNumbers.includes(playerNumber)
|
||||
);
|
||||
_playerNumbersWhoJustLeft.push(
|
||||
...playerNumbersWhoHaveDisconnected
|
||||
);
|
||||
for (const playerNumber of playerNumbersWhoHaveDisconnected) {
|
||||
// Temporarily save the username in another variable to be used for the notification,
|
||||
// as we're deleting its playerInfo just after.
|
||||
_temporaryPlayerNumberToUsername[
|
||||
playerNumber
|
||||
] = getPlayerUsername(playerNumber);
|
||||
}
|
||||
}
|
||||
|
||||
// Save the players info received from the host.
|
||||
// Avoid overwriting the whole object as it can mess up tests that rely on the object reference.
|
||||
for (const playerNumber in messageData.playersInfo) {
|
||||
_playersInfo[playerNumber] =
|
||||
messageData.playersInfo[playerNumber];
|
||||
}
|
||||
|
||||
const {
|
||||
messageName: answerMessageName,
|
||||
messageData: answerMessageData,
|
||||
@@ -1876,27 +2049,44 @@ namespace gdjs {
|
||||
heartbeatSentAt: messageData.now, // We send back the time we received, so that the host can compute the ping.
|
||||
});
|
||||
sendDataTo([messageSender], answerMessageName, answerMessageData);
|
||||
// We have received a heartbeat from the host, informing us of our ping,
|
||||
// so we can consider the connection as working.
|
||||
if (
|
||||
_playersInfo[currentPlayerNumber] !== undefined &&
|
||||
_playersInfo[currentPlayerNumber].ping !== undefined
|
||||
) {
|
||||
gdjs.multiplayer.markConnectionAsConnected();
|
||||
if (currentlyKnownPingForCurrentUser === undefined) {
|
||||
// We just connected, let's add ourselves to the list of players who just connected,
|
||||
// for the notification and the events.
|
||||
_playerNumbersWhoJustJoined.push(currentPlayerNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are the host, compute the pings based on:
|
||||
// If we are the host.
|
||||
|
||||
// If this is a new player, we're about to send them their ping, so we can consider them connected.
|
||||
if (!_playersInfo[playerNumber]) {
|
||||
_playerNumbersWhoJustJoined.push(playerNumber);
|
||||
}
|
||||
|
||||
// compute the pings based on:
|
||||
// - the time we received the heartbeat.
|
||||
// - the time the heartbeat was sent.
|
||||
const now = getTimeNow();
|
||||
const heartbeatSentAt = messageData.sentAt;
|
||||
const roundTripTime = Math.round(now - heartbeatSentAt);
|
||||
const playerLastHeartbeatInfo =
|
||||
_playersLastHeartbeatInfo[playerNumber] || {};
|
||||
const playerLastRoundTripTimes =
|
||||
playerLastHeartbeatInfo.lastRoundTripTimes || [];
|
||||
_playersLastRoundTripTimes[playerNumber] || [];
|
||||
playerLastRoundTripTimes.push(roundTripTime);
|
||||
if (playerLastRoundTripTimes.length > 5) {
|
||||
// Keep only the last 5 RTT to compute the average.
|
||||
playerLastRoundTripTimes.shift();
|
||||
}
|
||||
_playersLastHeartbeatInfo[playerNumber] = {
|
||||
lastRoundTripTimes: playerLastRoundTripTimes,
|
||||
};
|
||||
_playersLastRoundTripTimes[playerNumber] = playerLastRoundTripTimes;
|
||||
|
||||
let sum = 0;
|
||||
for (const lastRoundTripTime of playerLastRoundTripTimes) {
|
||||
@@ -1905,19 +2095,30 @@ namespace gdjs {
|
||||
const averagePing = Math.round(
|
||||
sum / playerLastRoundTripTimes.length / 2 // Divide by 2 to get the one way ping.
|
||||
);
|
||||
_playersPings[playerNumber] = averagePing;
|
||||
_playersInfo[playerNumber] = {
|
||||
ping: averagePing,
|
||||
playerId: messageData.playerId,
|
||||
username: messageData.username,
|
||||
};
|
||||
|
||||
// If there are new players, let's resend a heartbeat right away so that everyone is aware of them
|
||||
// on approximately the same frame.
|
||||
if (_playerNumbersWhoJustJoined.length) {
|
||||
const connectedPeerIds = gdjs.multiplayerPeerJsHelper.getAllPeers();
|
||||
const { messageName, messageData } = createHeartbeatMessage();
|
||||
sendDataTo(connectedPeerIds, messageName, messageData);
|
||||
lastHeartbeatTimestamp = getTimeNow();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getPlayerPing = (playerNumber: number) => {
|
||||
if (playerNumber < 1) {
|
||||
// Player 1 is the host, so we don't need to compute the ping.
|
||||
// Any negative number is invalid.
|
||||
const playerInfo = _playersInfo[playerNumber];
|
||||
if (!playerInfo) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _playersPings[playerNumber] || 0;
|
||||
return playerInfo.ping || 0;
|
||||
};
|
||||
|
||||
const getCurrentPlayerPing = () => {
|
||||
@@ -1928,12 +2129,16 @@ namespace gdjs {
|
||||
const markPlayerAsDisconnected = (playerNumber: number) => {
|
||||
logger.info(`Marking player ${playerNumber} as disconnected.`);
|
||||
_playerNumbersWhoJustLeft.push(playerNumber);
|
||||
// Temporarily save the username in another variable to be used for the notification,
|
||||
// as we're deleting its playerInfo just after.
|
||||
_temporaryPlayerNumberToUsername[playerNumber] = getPlayerUsername(
|
||||
playerNumber
|
||||
);
|
||||
|
||||
// If Player 1 has disconnected, just end the game.
|
||||
if (playerNumber === 1) {
|
||||
logger.info('Host has disconnected, ending the game.');
|
||||
_playersLastHeartbeatInfo = {};
|
||||
_playersPings = {};
|
||||
clearMessagesTempData();
|
||||
gdjs.multiplayer.handleLobbyGameEnded();
|
||||
return;
|
||||
}
|
||||
@@ -1941,23 +2146,42 @@ namespace gdjs {
|
||||
// Remove the player from the list of players.
|
||||
// This will cause the next hearbeat to not include this player
|
||||
// and the others will consider them as disconnected.
|
||||
delete _playersLastHeartbeatInfo[playerNumber];
|
||||
delete _playersPings[playerNumber];
|
||||
delete _playersLastRoundTripTimes[playerNumber];
|
||||
delete _playersInfo[playerNumber];
|
||||
// If we are the host, send a heartbeat right away so that everyone is aware of the disconnection
|
||||
// on approximately the same frame.
|
||||
if (gdjs.multiplayer.isPlayerHost()) {
|
||||
const connectedPeerIds = gdjs.multiplayerPeerJsHelper.getAllPeers();
|
||||
const { messageName, messageData } = createHeartbeatMessage();
|
||||
sendDataTo(connectedPeerIds, messageName, messageData);
|
||||
lastHeartbeatTimestamp = getTimeNow();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectedPeers = (runtimeScene: RuntimeScene) => {
|
||||
const getPlayerUsername = (playerNumber: number) => {
|
||||
return (
|
||||
(_playersInfo[playerNumber] || {}).username ||
|
||||
_temporaryPlayerNumberToUsername[playerNumber] ||
|
||||
`Player ${playerNumber}`
|
||||
);
|
||||
};
|
||||
|
||||
const getPlayerId = (playerNumber: number) => {
|
||||
return (_playersInfo[playerNumber] || {}).playerId || '';
|
||||
};
|
||||
|
||||
const handleJustDisconnectedPeers = (runtimeScene: RuntimeScene) => {
|
||||
// If the game is not running, we don't need to handle disconnected peers.
|
||||
if (!gdjs.multiplayer.isLobbyGameRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Players can disconnect if the P2P connection disconnects
|
||||
// or if we don't receive heartbeats for a while.
|
||||
const disconnectedPlayerNumbers: number[] = [];
|
||||
// We rely on the p2p helper to know who has disconnected.
|
||||
const justDisconnectedPlayerNumbers: number[] = [];
|
||||
|
||||
const disconnectedPeers = gdjs.multiplayerPeerJsHelper.getJustDisconnectedPeers();
|
||||
if (disconnectedPeers.length) {
|
||||
for (const disconnectedPeer of disconnectedPeers) {
|
||||
const justDisconnectedPeers = gdjs.multiplayerPeerJsHelper.getJustDisconnectedPeers();
|
||||
if (justDisconnectedPeers.length) {
|
||||
for (const disconnectedPeer of justDisconnectedPeers) {
|
||||
const disconnectedPlayerNumber =
|
||||
_peerIdToPlayerNumber[disconnectedPeer];
|
||||
if (!disconnectedPlayerNumber) {
|
||||
@@ -1965,11 +2189,11 @@ namespace gdjs {
|
||||
return;
|
||||
}
|
||||
logger.info(`Player ${disconnectedPlayerNumber} has disconnected.`);
|
||||
disconnectedPlayerNumbers.push(disconnectedPlayerNumber);
|
||||
justDisconnectedPlayerNumbers.push(disconnectedPlayerNumber);
|
||||
}
|
||||
}
|
||||
|
||||
for (const playerNumber of disconnectedPlayerNumbers) {
|
||||
for (const playerNumber of justDisconnectedPlayerNumbers) {
|
||||
// When a player disconnects, as the host, we look at all the instances
|
||||
// they own and decide what to do with them.
|
||||
if (gdjs.multiplayer.isPlayerHost()) {
|
||||
@@ -2000,29 +2224,60 @@ namespace gdjs {
|
||||
}
|
||||
};
|
||||
|
||||
const clearDisconnectedPeers = () => {
|
||||
_playerNumbersWhoJustLeft = [];
|
||||
};
|
||||
|
||||
const hasAnyPlayerLeft = () => {
|
||||
const hasAnyPlayerJustLeft = (): boolean => {
|
||||
return _playerNumbersWhoJustLeft.length > 0;
|
||||
};
|
||||
|
||||
const hasPlayerLeft = (playerNumber: number) => {
|
||||
const hasPlayerJustLeft = (playerNumber: number): boolean => {
|
||||
return _playerNumbersWhoJustLeft.includes(playerNumber);
|
||||
};
|
||||
|
||||
const getDisconnectedPlayers = () => {
|
||||
const getPlayersWhoJustLeft = (): number[] => {
|
||||
return _playerNumbersWhoJustLeft;
|
||||
};
|
||||
|
||||
const getNumberOfConnectedPlayers = () => {
|
||||
// Look at the player pings as a way to know how many players are in the lobby.
|
||||
return Object.keys(_playersPings).length;
|
||||
const getLatestPlayerWhoJustLeft = (): number => {
|
||||
return _playerNumbersWhoJustLeft[0] || 0;
|
||||
};
|
||||
const removePlayerWhoJustLeft = (): void => {
|
||||
// Avoid using shift for test purposes, as it modifies the reference.
|
||||
const playerNumberWhoLeft = _playerNumbersWhoJustLeft[0];
|
||||
if (playerNumberWhoLeft !== undefined) {
|
||||
_playerNumbersWhoJustLeft = _playerNumbersWhoJustLeft.slice(1);
|
||||
delete _temporaryPlayerNumberToUsername[playerNumberWhoLeft];
|
||||
}
|
||||
};
|
||||
|
||||
const hasAnyPlayerJustJoined = () => {
|
||||
return _playerNumbersWhoJustJoined.length > 0;
|
||||
};
|
||||
const hasPlayerJustJoined = (playerNumber: number): boolean => {
|
||||
return _playerNumbersWhoJustJoined.includes(playerNumber);
|
||||
};
|
||||
const getPlayersWhoJustJoined = () => {
|
||||
return _playerNumbersWhoJustJoined;
|
||||
};
|
||||
const getLatestPlayerWhoJustJoined = (): number => {
|
||||
return _playerNumbersWhoJustJoined[0] || 0;
|
||||
};
|
||||
const removePlayerWhoJustJoined = (): void => {
|
||||
// Avoid using shift for test purposes, as it modifies the reference.
|
||||
const playerNumberWhoJoined = _playerNumbersWhoJustJoined[0];
|
||||
if (playerNumberWhoJoined !== undefined) {
|
||||
_playerNumbersWhoJustJoined = _playerNumbersWhoJustJoined.slice(1);
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectedPlayers = () => {
|
||||
return Object.keys(_playersInfo).map((playerNumber) => ({
|
||||
playerNumber: parseInt(playerNumber, 10),
|
||||
playerId: _playersInfo[playerNumber].playerId,
|
||||
}));
|
||||
};
|
||||
const getNumberOfConnectedPlayers = () => {
|
||||
// Look at the player info as a way to know how many players are in the lobby.
|
||||
// This object is updated when heartbeats are sent and received.
|
||||
return Object.keys(_playersInfo).length;
|
||||
};
|
||||
const isPlayerConnected = (playerNumber: number) => {
|
||||
return _playersPings[playerNumber] !== undefined;
|
||||
return _playersInfo[playerNumber] !== undefined;
|
||||
};
|
||||
|
||||
const endGameMessageName = '#endGame';
|
||||
@@ -2066,20 +2321,26 @@ namespace gdjs {
|
||||
|
||||
// If the message is received more than 1 time, we just ignore it and end the game.
|
||||
|
||||
_playersLastHeartbeatInfo = {};
|
||||
_playersPings = {};
|
||||
clearMessagesTempData();
|
||||
gdjs.multiplayer.handleLobbyGameEnded();
|
||||
};
|
||||
|
||||
const updatePlayersPingsForTests = (playersPings) => {
|
||||
_playersPings = playersPings;
|
||||
const clearMessagesTempData = () => {
|
||||
_playersLastRoundTripTimes = {};
|
||||
_playersInfo = {};
|
||||
lastReceivedGameSyncDataUpdates.clear();
|
||||
lastReceivedSceneSyncDataUpdates.clear();
|
||||
processedCustomMessagesCache.clear();
|
||||
_playerNumbersWhoJustLeft = [];
|
||||
_playerNumbersWhoJustJoined = [];
|
||||
expectedMessageAcknowledgements = {};
|
||||
_lastClockReceivedByInstanceByScene = {};
|
||||
};
|
||||
|
||||
return {
|
||||
sendDataTo,
|
||||
// Acks.
|
||||
addExpectedMessageAcknowledgement,
|
||||
clearExpectedMessageAcknowledgements,
|
||||
handleAcknowledgeMessagesReceived,
|
||||
resendClearOrCancelAcknowledgedMessages,
|
||||
// Instance ownership.
|
||||
@@ -2113,23 +2374,36 @@ namespace gdjs {
|
||||
createUpdateGameMessage,
|
||||
handleUpdateGameMessagesToSend,
|
||||
handleUpdateGameMessagesReceived,
|
||||
handleSavedUpdateMessages,
|
||||
// Heartbeats.
|
||||
handleHeartbeatsToSend,
|
||||
handleHeartbeatsReceived,
|
||||
// Connection/Disonnection.
|
||||
// Pings & usernames.
|
||||
getPlayerPing,
|
||||
getCurrentPlayerPing,
|
||||
updatePlayersPingsForTests,
|
||||
handleDisconnectedPeers,
|
||||
clearDisconnectedPeers,
|
||||
hasAnyPlayerLeft,
|
||||
hasPlayerLeft,
|
||||
getDisconnectedPlayers,
|
||||
getPlayerUsername,
|
||||
getPlayerId,
|
||||
// Connected players.
|
||||
handleJustDisconnectedPeers,
|
||||
getConnectedPlayers,
|
||||
getNumberOfConnectedPlayers,
|
||||
isPlayerConnected,
|
||||
// Leaving players.
|
||||
hasAnyPlayerJustLeft,
|
||||
hasPlayerJustLeft,
|
||||
getPlayersWhoJustLeft,
|
||||
getLatestPlayerWhoJustLeft,
|
||||
removePlayerWhoJustLeft,
|
||||
// Joining players.
|
||||
hasAnyPlayerJustJoined,
|
||||
hasPlayerJustJoined,
|
||||
getPlayersWhoJustJoined,
|
||||
getLatestPlayerWhoJustJoined,
|
||||
removePlayerWhoJustJoined,
|
||||
// End game.
|
||||
sendEndGameMessage,
|
||||
handleEndGameMessages,
|
||||
clearMessagesTempData,
|
||||
};
|
||||
};
|
||||
|
||||
|
@@ -208,7 +208,10 @@ namespace gdjs {
|
||||
};
|
||||
|
||||
const handleChangeVariableOwnerMessagesToSend = function () {
|
||||
if (!gdjs.multiplayer.isLobbyGameRunning()) {
|
||||
if (
|
||||
!gdjs.multiplayer.isLobbyGameRunning() ||
|
||||
!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@@ -11,6 +11,8 @@ namespace gdjs {
|
||||
|
||||
let canLobbyBeClosed = true;
|
||||
|
||||
const notificationContainerIds: string[] = [];
|
||||
|
||||
export const getDomElementContainer = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): HTMLDivElement | null => {
|
||||
@@ -387,7 +389,7 @@ namespace gdjs {
|
||||
// to allow the player to leave the lobby.
|
||||
setTimeout(() => {
|
||||
closeContainer.style.visibility = 'inherit';
|
||||
}, 5000);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -398,7 +400,6 @@ namespace gdjs {
|
||||
) {
|
||||
showNotification(
|
||||
runtimeScene,
|
||||
'error-notification',
|
||||
'An error occurred while displaying the game lobbies, please try again.',
|
||||
'error'
|
||||
);
|
||||
@@ -411,37 +412,82 @@ namespace gdjs {
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
playerName: string
|
||||
) {
|
||||
showNotification(
|
||||
runtimeScene,
|
||||
'player-left-notification',
|
||||
`${playerName} has left the game.`,
|
||||
'warning'
|
||||
);
|
||||
showNotification(runtimeScene, `${playerName} left.`, 'warning');
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, display, and hide a notification when a player leaves the game.
|
||||
* Create, display, and hide a notification when a player joins the game.
|
||||
*/
|
||||
export const displayPlayerJoinedNotification = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
playerName: string
|
||||
) {
|
||||
showNotification(runtimeScene, `${playerName} joined.`, 'success');
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, display, and hide a notification when an error happens on connection.
|
||||
*/
|
||||
export const displayConnectionErrorNotification = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
showNotification(
|
||||
runtimeScene,
|
||||
'connection-error-notification',
|
||||
'Could not connect to other players.',
|
||||
'error'
|
||||
);
|
||||
};
|
||||
|
||||
const removeNotificationAndShiftOthers = function (
|
||||
notificationContainerId: string
|
||||
) {
|
||||
const notification = document.getElementById(notificationContainerId);
|
||||
if (!notification) {
|
||||
logger.error('Notification not found.');
|
||||
return;
|
||||
}
|
||||
const index = notificationContainerIds.indexOf(notificationContainerId);
|
||||
if (index !== -1) {
|
||||
notificationContainerIds.splice(index, 1);
|
||||
}
|
||||
notification.remove();
|
||||
|
||||
// Shift the other notifications up.
|
||||
for (let i = 0; i < notificationContainerIds.length; i++) {
|
||||
const notification = document.getElementById(
|
||||
notificationContainerIds[i]
|
||||
);
|
||||
if (!notification) {
|
||||
logger.error('Notification not found.');
|
||||
continue;
|
||||
}
|
||||
notification.style.top = `${12 + i * 32}px`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to show a notification to the user, that disappears automatically.
|
||||
*/
|
||||
export const showNotification = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
id: string,
|
||||
content: string,
|
||||
type: 'success' | 'warning' | 'error'
|
||||
) {
|
||||
// When we show a notification, we add it below the other ones.
|
||||
// We also remove the oldest one if there are too many > 5.
|
||||
if (notificationContainerIds.length > 5) {
|
||||
const oldestNotificationId = notificationContainerIds.shift();
|
||||
if (!oldestNotificationId) {
|
||||
logger.error('No oldest notification ID found.');
|
||||
return;
|
||||
}
|
||||
|
||||
removeNotificationAndShiftOthers(oldestNotificationId);
|
||||
}
|
||||
|
||||
// We generate a random ID for the notification, so they can stack.
|
||||
const id = `notification-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
const domContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
@@ -461,7 +507,8 @@ namespace gdjs {
|
||||
: type === 'warning'
|
||||
? '#FFA500'
|
||||
: '#FF0000';
|
||||
divContainer.style.top = '12px';
|
||||
// Space the notifications vertically, based on how many there are.
|
||||
divContainer.style.top = `${12 + notificationContainerIds.length * 32}px`;
|
||||
divContainer.style.right = '16px';
|
||||
divContainer.style.padding = '6px 32px 6px 6px';
|
||||
// Use zIndex 1 to make sure it is below the iframe.
|
||||
@@ -496,8 +543,9 @@ namespace gdjs {
|
||||
loggedText.style.margin = '0px';
|
||||
|
||||
divContainer.appendChild(loggedText);
|
||||
|
||||
domContainer.appendChild(divContainer);
|
||||
notificationContainerIds.push(id);
|
||||
|
||||
const animationTime = 700;
|
||||
const notificationTime = 5000;
|
||||
setTimeout(() => {
|
||||
@@ -518,7 +566,7 @@ namespace gdjs {
|
||||
}, notificationTime);
|
||||
// Use timeout because onanimationend listener does not work.
|
||||
setTimeout(() => {
|
||||
divContainer.remove();
|
||||
removeNotificationAndShiftOthers(id);
|
||||
}, notificationTime + animationTime);
|
||||
};
|
||||
|
||||
|
@@ -1,16 +1,12 @@
|
||||
namespace gdjs {
|
||||
const logger = new gdjs.Logger('Multiplayer');
|
||||
|
||||
type Lobby = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
players: { playerId: string; status: string }[];
|
||||
};
|
||||
export namespace multiplayer {
|
||||
/** Set to true in testing to avoid relying on the multiplayer extension. */
|
||||
export let disableMultiplayerForTesting = false;
|
||||
|
||||
export let _isReadyToSendOrReceiveGameUpdateMessages = false;
|
||||
|
||||
let _isGameRegistered: boolean | null = null;
|
||||
let _isCheckingIfGameIsRegistered = false;
|
||||
let _isWaitingForLogin = false;
|
||||
@@ -20,8 +16,6 @@ namespace gdjs {
|
||||
let _hasLobbyGameJustEnded = false;
|
||||
let _lobbyId: string | null = null;
|
||||
let _connectionId: string | null = null;
|
||||
export let _lobby: Lobby | null = null;
|
||||
let _playerPublicProfiles: { id: string; username?: string }[] = [];
|
||||
|
||||
// Communication methods.
|
||||
let _lobbiesMessageCallback: ((event: MessageEvent) => void) | null = null;
|
||||
@@ -46,6 +40,11 @@ namespace gdjs {
|
||||
|
||||
if (disableMultiplayerForTesting) return;
|
||||
|
||||
gdjs.multiplayerMessageManager.handleHeartbeatsToSend();
|
||||
gdjs.multiplayerMessageManager.handleJustDisconnectedPeers(
|
||||
runtimeScene
|
||||
);
|
||||
|
||||
gdjs.multiplayerMessageManager.handleChangeInstanceOwnerMessagesReceived(
|
||||
runtimeScene
|
||||
);
|
||||
@@ -66,8 +65,6 @@ namespace gdjs {
|
||||
gdjs.multiplayerMessageManager.handleUpdateSceneMessagesReceived(
|
||||
runtimeScene
|
||||
);
|
||||
gdjs.multiplayerMessageManager.handleHeartbeatsToSend();
|
||||
gdjs.multiplayerMessageManager.handleDisconnectedPeers(runtimeScene);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -75,6 +72,13 @@ namespace gdjs {
|
||||
(runtimeScene: gdjs.RuntimeScene) => {
|
||||
if (disableMultiplayerForTesting) return;
|
||||
|
||||
// Handle joining and leaving players to show notifications accordingly.
|
||||
handleLeavingPlayer(runtimeScene);
|
||||
handleJoiningPlayer(runtimeScene);
|
||||
|
||||
// Then look at the heartbeats received to know if a new player has joined/left.
|
||||
gdjs.multiplayerMessageManager.handleHeartbeatsReceived();
|
||||
|
||||
gdjs.multiplayerMessageManager.handleDestroyInstanceMessagesReceived(
|
||||
runtimeScene
|
||||
);
|
||||
@@ -85,9 +89,6 @@ namespace gdjs {
|
||||
gdjs.multiplayerMessageManager.handleUpdateSceneMessagesToSend(
|
||||
runtimeScene
|
||||
);
|
||||
gdjs.multiplayerMessageManager.handleHeartbeatsReceived();
|
||||
handleLeavingPlayer(runtimeScene);
|
||||
gdjs.multiplayerMessageManager.clearDisconnectedPeers();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -144,6 +145,9 @@ namespace gdjs {
|
||||
if (playerToken) {
|
||||
url.searchParams.set('playerToken', playerToken);
|
||||
}
|
||||
// Increment this value when a new feature is introduced so we can
|
||||
// adapt the interface of the lobbies.
|
||||
url.searchParams.set('multiplayerVersion', '2');
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
@@ -156,6 +160,9 @@ namespace gdjs {
|
||||
|
||||
export const isLobbyGameRunning = () => _isLobbyGameRunning;
|
||||
|
||||
export const isReadyToSendOrReceiveGameUpdateMessages = () =>
|
||||
_isReadyToSendOrReceiveGameUpdateMessages;
|
||||
|
||||
/**
|
||||
* Returns true if the game has just ended,
|
||||
* useful to switch back to to the main menu.
|
||||
@@ -166,17 +173,9 @@ namespace gdjs {
|
||||
* Returns the number of players in the lobby.
|
||||
*/
|
||||
export const getPlayersInLobbyCount = () => {
|
||||
// If the game has not started yet, look at the lobby.
|
||||
if (!_isLobbyGameRunning && _lobby) {
|
||||
return _lobby.players.length;
|
||||
}
|
||||
|
||||
// If the game has started, look at the pings received from the players.
|
||||
if (_isLobbyGameRunning) {
|
||||
return gdjs.multiplayerMessageManager.getNumberOfConnectedPlayers();
|
||||
}
|
||||
|
||||
return 0;
|
||||
// Whether the lobby game has started or not, the number of players in the lobby
|
||||
// is the number of connected players.
|
||||
return gdjs.multiplayerMessageManager.getNumberOfConnectedPlayers();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -195,38 +194,12 @@ namespace gdjs {
|
||||
return playerNumber === 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the player ID of the player at the given number in the lobby.
|
||||
* The number is shifted by one, so that the first player has number 1.
|
||||
*/
|
||||
const getPlayerId = (playerNumber: number) => {
|
||||
if (!_lobby) {
|
||||
return '';
|
||||
}
|
||||
const index = playerNumber - 1;
|
||||
if (index < 0 || index >= _lobby.players.length) {
|
||||
return '';
|
||||
}
|
||||
return _lobby.players[index].playerId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the player username at the given number in the lobby.
|
||||
* The number is shifted by one, so that the first player has number 1.
|
||||
*/
|
||||
export const getPlayerUsername = (playerNumber: number) => {
|
||||
const playerId = getPlayerId(playerNumber);
|
||||
if (!playerId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const playerPublicProfile = _playerPublicProfiles.find(
|
||||
(profile) => profile.id === playerId
|
||||
);
|
||||
|
||||
return playerPublicProfile
|
||||
? playerPublicProfile.username
|
||||
: `Player ${playerNumber}`;
|
||||
return gdjs.multiplayerMessageManager.getPlayerUsername(playerNumber);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -238,28 +211,33 @@ namespace gdjs {
|
||||
};
|
||||
|
||||
const handleLeavingPlayer = (runtimeScene: gdjs.RuntimeScene) => {
|
||||
const disconnectedPlayers = gdjs.multiplayerMessageManager.getDisconnectedPlayers();
|
||||
if (disconnectedPlayers.length > 0) {
|
||||
for (const playerNumber of disconnectedPlayers) {
|
||||
const playerLeftId = getPlayerId(playerNumber);
|
||||
|
||||
if (!playerLeftId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerLeftPublicProfile = _playerPublicProfiles.find(
|
||||
(profile) => profile.id === playerLeftId
|
||||
);
|
||||
|
||||
if (playerLeftPublicProfile) {
|
||||
gdjs.multiplayerComponents.displayPlayerLeftNotification(
|
||||
runtimeScene,
|
||||
(playerLeftPublicProfile && playerLeftPublicProfile.username) ||
|
||||
'Player'
|
||||
);
|
||||
}
|
||||
}
|
||||
const lastestPlayerWhoJustLeft = gdjs.multiplayerMessageManager.getLatestPlayerWhoJustLeft();
|
||||
if (lastestPlayerWhoJustLeft) {
|
||||
const playerUsername = getPlayerUsername(lastestPlayerWhoJustLeft);
|
||||
gdjs.multiplayerComponents.displayPlayerLeftNotification(
|
||||
runtimeScene,
|
||||
playerUsername
|
||||
);
|
||||
}
|
||||
// We remove the players who just left 1 by 1, so that they can be treated in different frames.
|
||||
// This is especially important if the expression to know the latest player who just left is used,
|
||||
// to avoid missing a player leaving.
|
||||
gdjs.multiplayerMessageManager.removePlayerWhoJustLeft();
|
||||
};
|
||||
|
||||
const handleJoiningPlayer = (runtimeScene: gdjs.RuntimeScene) => {
|
||||
const lastestPlayerWhoJustJoined = gdjs.multiplayerMessageManager.getLatestPlayerWhoJustJoined();
|
||||
if (lastestPlayerWhoJustJoined) {
|
||||
const playerUsername = getPlayerUsername(lastestPlayerWhoJustJoined);
|
||||
gdjs.multiplayerComponents.displayPlayerJoinedNotification(
|
||||
runtimeScene,
|
||||
playerUsername
|
||||
);
|
||||
}
|
||||
// We remove the players who just joined 1 by 1, so that they can be treated in different frames.
|
||||
// This is especially important if the expression to know the latest player who just joined is used,
|
||||
// to avoid missing a player joining.
|
||||
gdjs.multiplayerMessageManager.removePlayerWhoJustJoined();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -298,61 +276,7 @@ namespace gdjs {
|
||||
);
|
||||
};
|
||||
|
||||
const getUserPublicProfile = async (
|
||||
userId: string,
|
||||
isDev: boolean
|
||||
): Promise<{ id: string; username?: string }> => {
|
||||
const rootApi = isDev
|
||||
? 'https://api-dev.gdevelop.io'
|
||||
: 'https://api.gdevelop.io';
|
||||
const url = `${rootApi}/user/user-public-profile/${userId}`;
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const updatePlayerPublicProfiles = async (isDev: boolean) => {
|
||||
if (!_lobby) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerIds = _lobby.players.map((player) => player.playerId);
|
||||
const currentPlayerPublicProfileIds = _playerPublicProfiles.map(
|
||||
(profile) => profile.id
|
||||
);
|
||||
const addedPlayerIds = playerIds.filter(
|
||||
(id) => !currentPlayerPublicProfileIds.includes(id)
|
||||
);
|
||||
const removedPlayerIds = currentPlayerPublicProfileIds.filter(
|
||||
(id) => !playerIds.includes(id)
|
||||
);
|
||||
if (addedPlayerIds.length === 0 && removedPlayerIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedPlayerIds.length > 0) {
|
||||
const addedPlayerPublicProfiles = await Promise.all(
|
||||
addedPlayerIds.map(async (id) => {
|
||||
const userPublicProfile = await getUserPublicProfile(id, isDev);
|
||||
return userPublicProfile;
|
||||
})
|
||||
);
|
||||
|
||||
_playerPublicProfiles = [
|
||||
..._playerPublicProfiles,
|
||||
...addedPlayerPublicProfiles,
|
||||
];
|
||||
}
|
||||
|
||||
if (removedPlayerIds.length > 0) {
|
||||
const updatedPlayerPublicProfiles = _playerPublicProfiles.filter(
|
||||
(profile) => !removedPlayerIds.includes(profile.id)
|
||||
);
|
||||
|
||||
_playerPublicProfiles = updatedPlayerPublicProfiles;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLobbyJoinEvent = function (
|
||||
const handleJoinLobbyEvent = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
lobbyId: string
|
||||
) {
|
||||
@@ -367,7 +291,6 @@ namespace gdjs {
|
||||
_connectionId = null;
|
||||
playerNumber = null;
|
||||
_lobbyId = null;
|
||||
_lobby = null;
|
||||
_websocket = null;
|
||||
}
|
||||
|
||||
@@ -460,15 +383,9 @@ namespace gdjs {
|
||||
}
|
||||
case 'lobbyUpdated': {
|
||||
const messageData = messageContent.data;
|
||||
const lobby = messageData.lobby;
|
||||
const positionInLobby = messageData.positionInLobby;
|
||||
if (!lobby) {
|
||||
logger.error('No lobby received');
|
||||
return;
|
||||
}
|
||||
handleLobbyUpdatedEvent({
|
||||
runtimeScene,
|
||||
updatedLobby: lobby,
|
||||
positionInLobby,
|
||||
});
|
||||
break;
|
||||
@@ -502,12 +419,13 @@ namespace gdjs {
|
||||
return;
|
||||
}
|
||||
const peerId = messageData.peerId;
|
||||
if (!peerId) {
|
||||
const compressionMethod = messageData.compressionMethod;
|
||||
if (!peerId || !compressionMethod) {
|
||||
logger.error('Malformed message received');
|
||||
return;
|
||||
}
|
||||
|
||||
handlePeerIdEvent({ peerId });
|
||||
handlePeerIdEvent({ peerId, compressionMethod });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -632,37 +550,25 @@ namespace gdjs {
|
||||
);
|
||||
};
|
||||
|
||||
const handleLobbyLeaveEvent = function () {
|
||||
const handleLeaveLobbyEvent = function () {
|
||||
if (_websocket) {
|
||||
_websocket.close();
|
||||
}
|
||||
_connectionId = null;
|
||||
playerNumber = null;
|
||||
_lobbyId = null;
|
||||
_lobby = null;
|
||||
_websocket = null;
|
||||
};
|
||||
|
||||
const handleLobbyUpdatedEvent = function ({
|
||||
runtimeScene,
|
||||
updatedLobby,
|
||||
positionInLobby,
|
||||
}: {
|
||||
runtimeScene: gdjs.RuntimeScene;
|
||||
updatedLobby: Lobby;
|
||||
positionInLobby: number;
|
||||
}) {
|
||||
// Update the object representing the lobby in the extension.
|
||||
_lobby = updatedLobby;
|
||||
|
||||
// If the lobby is playing, do not update anything.
|
||||
if (updatedLobby.status === 'playing') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the profiles so we can use the usernames of the players.
|
||||
updatePlayerPublicProfiles(isUsingGDevelopDevelopmentEnvironment);
|
||||
|
||||
// This is mainly useful when joining a lobby, or when the lobby is updated before the game starts.
|
||||
// The position in lobby should never change after the game has started (the WS is closed anyway).
|
||||
playerNumber = positionInLobby;
|
||||
|
||||
// If the player is in the lobby, tell the lobbies window that the lobby has been updated,
|
||||
@@ -672,7 +578,6 @@ namespace gdjs {
|
||||
);
|
||||
|
||||
if (!lobbiesIframe || !lobbiesIframe.contentWindow) {
|
||||
logger.info('The lobbies iframe is not opened, not sending message.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,7 +650,7 @@ namespace gdjs {
|
||||
runtimeScene
|
||||
);
|
||||
// Do as if the player left the lobby.
|
||||
handleLobbyLeaveEvent();
|
||||
handleLeaveLobbyEvent();
|
||||
removeLobbiesContainer(runtimeScene);
|
||||
focusOnGame(runtimeScene);
|
||||
return;
|
||||
@@ -774,15 +679,22 @@ namespace gdjs {
|
||||
let heartbeatUrl = `${rootApi}/play/game/${gameId}/public-lobby/${_lobbyId}/action/heartbeat`;
|
||||
headers['Authorization'] = `player-game-token ${playerToken}`;
|
||||
heartbeatUrl += `?playerId=${playerId}`;
|
||||
const players = gdjs.multiplayerMessageManager.getConnectedPlayers();
|
||||
await fetch(heartbeatUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
players,
|
||||
}),
|
||||
});
|
||||
}, heartbeatInterval);
|
||||
}
|
||||
|
||||
// If we are connected to players, then the game can start.
|
||||
logger.info('Lobby game has started.');
|
||||
// In case we're joining an existing lobby, read the saved messages to catch-up with the game state.
|
||||
gdjs.multiplayerMessageManager.handleSavedUpdateMessages(runtimeScene);
|
||||
_isReadyToSendOrReceiveGameUpdateMessages = true;
|
||||
_hasLobbyGameJustStarted = true;
|
||||
_isLobbyGameRunning = true;
|
||||
removeLobbiesContainer(runtimeScene);
|
||||
@@ -802,8 +714,8 @@ namespace gdjs {
|
||||
_hasLobbyGameJustEnded = true;
|
||||
_isLobbyGameRunning = false;
|
||||
_lobbyId = null;
|
||||
_lobby = null;
|
||||
playerNumber = null;
|
||||
_isReadyToSendOrReceiveGameUpdateMessages = false;
|
||||
if (_lobbyHeartbeatInterval) {
|
||||
clearInterval(_lobbyHeartbeatInterval);
|
||||
}
|
||||
@@ -812,15 +724,22 @@ namespace gdjs {
|
||||
gdjs.multiplayerPeerJsHelper.disconnectFromAllPeers();
|
||||
|
||||
// Clear the expected acknowledgments, as the game is ending.
|
||||
gdjs.multiplayerMessageManager.clearExpectedMessageAcknowledgements();
|
||||
gdjs.multiplayerMessageManager.clearMessagesTempData();
|
||||
};
|
||||
|
||||
/**
|
||||
* When the game receives the information of the peerId, then
|
||||
* the player can connect to the peer.
|
||||
*/
|
||||
const handlePeerIdEvent = function ({ peerId }: { peerId: string }) {
|
||||
// When a peerId is received, trigger a P2P connection with the peer.
|
||||
const handlePeerIdEvent = function ({
|
||||
peerId,
|
||||
compressionMethod,
|
||||
}: {
|
||||
peerId: string;
|
||||
compressionMethod: gdjs.multiplayerPeerJsHelper.CompressionMethod;
|
||||
}) {
|
||||
// When a peerId is received, trigger a P2P connection with the peer, just after setting the compression method.
|
||||
gdjs.multiplayerPeerJsHelper.setCompressionMethod(compressionMethod);
|
||||
const currentPeerId = gdjs.multiplayerPeerJsHelper.getCurrentId();
|
||||
if (!currentPeerId) {
|
||||
logger.error(
|
||||
@@ -842,7 +761,7 @@ namespace gdjs {
|
||||
* players in the lobby via the websocket.
|
||||
* It will then trigger an event from the websocket to all players in the lobby.
|
||||
*/
|
||||
const handleGameCountdownStartMessage = function () {
|
||||
const handleStartGameCountdownMessage = function () {
|
||||
if (!_websocket) {
|
||||
logger.error(
|
||||
'No connection to send the start countdown message. Are you connected to a lobby?'
|
||||
@@ -863,7 +782,7 @@ namespace gdjs {
|
||||
* players in the lobby via the websocket.
|
||||
* It will then trigger an event from the websocket to all players in the lobby.
|
||||
*/
|
||||
const handleGameStartMessage = function () {
|
||||
const handleStartGameMessage = function () {
|
||||
if (!_websocket) {
|
||||
logger.error(
|
||||
'No connection to send the start countdown message. Are you connected to a lobby?'
|
||||
@@ -877,6 +796,48 @@ namespace gdjs {
|
||||
connectionType: 'lobby',
|
||||
})
|
||||
);
|
||||
|
||||
// As the host, start sending messages to the players.
|
||||
_isReadyToSendOrReceiveGameUpdateMessages = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* When the game receives a join game message from the lobby, send it via the WS
|
||||
* waiting for a peerId to be received and that the connection happens automatically.
|
||||
*/
|
||||
const handleJoinGameMessage = function () {
|
||||
if (!_websocket) {
|
||||
logger.error(
|
||||
'No connection to send the start countdown message. Are you connected to a lobby?'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_websocket.send(
|
||||
JSON.stringify({
|
||||
action: 'joinGame',
|
||||
connectionType: 'lobby',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* When the first heartbeat is received, we consider the connection to the host as working,
|
||||
* we inform the backend services that the connection is ready, so it can start the game when
|
||||
* everyone is ready.
|
||||
*/
|
||||
export const markConnectionAsConnected = function () {
|
||||
if (!_websocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
_websocket.send(
|
||||
JSON.stringify({
|
||||
action: 'updateConnection',
|
||||
connectionType: 'lobby',
|
||||
status: 'connected',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -941,7 +902,7 @@ namespace gdjs {
|
||||
* Helper to send the ID from PeerJS to the lobby players.
|
||||
*/
|
||||
const sendPeerId = function () {
|
||||
if (!_websocket || !_lobby) {
|
||||
if (!_websocket) {
|
||||
logger.error(
|
||||
'No connection to send the message. Are you connected to a lobby?'
|
||||
);
|
||||
@@ -997,19 +958,23 @@ namespace gdjs {
|
||||
throw new Error('Malformed message.');
|
||||
}
|
||||
|
||||
handleLobbyJoinEvent(runtimeScene, event.data.lobbyId);
|
||||
handleJoinLobbyEvent(runtimeScene, event.data.lobbyId);
|
||||
break;
|
||||
}
|
||||
case 'startGameCountdown': {
|
||||
handleGameCountdownStartMessage();
|
||||
handleStartGameCountdownMessage();
|
||||
break;
|
||||
}
|
||||
case 'startGame': {
|
||||
handleGameStartMessage();
|
||||
handleStartGameMessage();
|
||||
break;
|
||||
}
|
||||
case 'leaveLobby': {
|
||||
handleLobbyLeaveEvent();
|
||||
handleLeaveLobbyEvent();
|
||||
break;
|
||||
}
|
||||
case 'joinGame': {
|
||||
handleJoinGameMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1247,9 +1212,9 @@ namespace gdjs {
|
||||
/**
|
||||
* Action to allow the player to leave the lobby in-game.
|
||||
*/
|
||||
export const leaveGameLobby = async (runtimeScene: gdjs.RuntimeScene) => {
|
||||
export const leaveGameLobby = async () => {
|
||||
// Handle the case where the game has not started yet, so the player is in the lobby.
|
||||
handleLobbyLeaveEvent();
|
||||
handleLeaveLobbyEvent();
|
||||
// Handle the case where the game has started, so the player is in the game and connected to other players.
|
||||
handleLobbyGameEnded();
|
||||
};
|
||||
|
@@ -254,6 +254,11 @@ namespace gdjs {
|
||||
connection.on('close', () => {
|
||||
_onDisconnect(connection.peer);
|
||||
});
|
||||
connection.on('iceStateChanged', (state) => {
|
||||
if (state === 'disconnected') {
|
||||
_onDisconnect(connection.peer);
|
||||
}
|
||||
});
|
||||
|
||||
// Regularly check for disconnection as the built in way is not reliable.
|
||||
(function disconnectChecker() {
|
||||
@@ -428,7 +433,7 @@ namespace gdjs {
|
||||
export const isReady = () => ready;
|
||||
|
||||
/**
|
||||
* Return any disconnected peers.
|
||||
* Return peers that have disconnected in the frame.
|
||||
*/
|
||||
export const getJustDisconnectedPeers = () => justDisconnectedPeers;
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import { getHelpLink } from '../Utils/HelpLink';
|
||||
import Window from '../Utils/Window';
|
||||
import SelectField from '../UI/SelectField';
|
||||
import SelectOption from '../UI/SelectOption';
|
||||
import InlineCheckbox from '../UI/InlineCheckbox';
|
||||
|
||||
const defaultMaximumNumberOfPlayers = 4;
|
||||
const minimumValueForMaximumNumberOfPlayers = 2;
|
||||
@@ -37,6 +38,9 @@ const MultiplayerAdmin = ({ gameId }: Props) => {
|
||||
const [isSaving, setIsSaving] = React.useState<boolean>(false);
|
||||
const [maxPlayersValue, setMaxPlayersValue] = React.useState<number>(2);
|
||||
const [minPlayersValue, setMinPlayersValue] = React.useState<number>(1);
|
||||
const [canJoinAfterStart, setCanJoinAfterStart] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const { getAuthorizationHeader, profile, limits } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
@@ -55,6 +59,7 @@ const MultiplayerAdmin = ({ gameId }: Props) => {
|
||||
if (lobbyConfiguration) {
|
||||
setMaxPlayersValue(lobbyConfiguration.maxPlayers);
|
||||
setMinPlayersValue(lobbyConfiguration.minPlayers);
|
||||
setCanJoinAfterStart(lobbyConfiguration.canJoinAfterStart);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -165,6 +170,7 @@ const MultiplayerAdmin = ({ gameId }: Props) => {
|
||||
gameId,
|
||||
maxPlayers: maxPlayersValue,
|
||||
minPlayers: minPlayersValue,
|
||||
canJoinAfterStart,
|
||||
}
|
||||
);
|
||||
setLobbyConfiguration(updatedLobbyConfiguration);
|
||||
@@ -183,13 +189,21 @@ const MultiplayerAdmin = ({ gameId }: Props) => {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[getAuthorizationHeader, gameId, userId, maxPlayersValue, minPlayersValue]
|
||||
[
|
||||
getAuthorizationHeader,
|
||||
gameId,
|
||||
userId,
|
||||
maxPlayersValue,
|
||||
minPlayersValue,
|
||||
canJoinAfterStart,
|
||||
]
|
||||
);
|
||||
|
||||
const hasUnsavedModifications =
|
||||
lobbyConfiguration &&
|
||||
(lobbyConfiguration.maxPlayers !== maxPlayersValue ||
|
||||
lobbyConfiguration.minPlayers !== minPlayersValue);
|
||||
lobbyConfiguration.minPlayers !== minPlayersValue ||
|
||||
lobbyConfiguration.canJoinAfterStart !== canJoinAfterStart);
|
||||
const canSave = hasUnsavedModifications;
|
||||
|
||||
const helpLink = getHelpLink('/all-features/multiplayer/');
|
||||
@@ -256,6 +270,17 @@ const MultiplayerAdmin = ({ gameId }: Props) => {
|
||||
{maxPlayersSelectOptions}
|
||||
</SelectField>
|
||||
</Line>
|
||||
<Line noMargin>
|
||||
<InlineCheckbox
|
||||
label={
|
||||
<Trans>Allow players to join after the game has started</Trans>
|
||||
}
|
||||
checked={canJoinAfterStart}
|
||||
onCheck={(e, checked) => {
|
||||
setCanJoinAfterStart(checked);
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<LeftLoader isLoading={isSaving}>
|
||||
<RaisedButton
|
||||
|
@@ -95,6 +95,7 @@ export type LobbyConfiguration = {|
|
||||
gameId: string,
|
||||
maxPlayers: number,
|
||||
minPlayers: number,
|
||||
canJoinAfterStart: boolean,
|
||||
|};
|
||||
|
||||
export const shortenUuidForDisplay = (uuid: string): string =>
|
||||
@@ -483,16 +484,18 @@ export const updateLobbyConfiguration = async (
|
||||
gameId,
|
||||
maxPlayers,
|
||||
minPlayers,
|
||||
canJoinAfterStart,
|
||||
}: {|
|
||||
gameId: string,
|
||||
maxPlayers: number,
|
||||
minPlayers: number,
|
||||
canJoinAfterStart: boolean,
|
||||
|}
|
||||
): Promise<LobbyConfiguration> => {
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
const response = await axios.patch(
|
||||
`${GDevelopPlayApi.baseUrl}/game/${gameId}/lobby-configuration`,
|
||||
{ maxPlayers, minPlayers },
|
||||
{ maxPlayers, minPlayers, canJoinAfterStart },
|
||||
{
|
||||
params: { userId },
|
||||
headers: {
|
||||
|
Reference in New Issue
Block a user