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:
Clément Pasteau
2024-07-23 16:13:50 +02:00
committed by GitHub
parent 75f049d911
commit 6a3df62598
9 changed files with 1237 additions and 580 deletions

View File

@@ -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(

View File

@@ -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,
};
};

View File

@@ -208,7 +208,10 @@ namespace gdjs {
};
const handleChangeVariableOwnerMessagesToSend = function () {
if (!gdjs.multiplayer.isLobbyGameRunning()) {
if (
!gdjs.multiplayer.isLobbyGameRunning() ||
!gdjs.multiplayer.isReadyToSendOrReceiveGameUpdateMessages()
) {
return;
}

View File

@@ -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);
};

View File

@@ -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();
};

View File

@@ -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

View File

@@ -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

View File

@@ -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: {