namespace gdjs { const logger = new gdjs.Logger('Multiplayer'); /** * @category Multiplayer */ export namespace multiplayerComponents { const loaderContainerId = 'loader-container'; const lobbiesRootContainerId = 'lobbies-root-container'; const lobbiesFrameContainerId = 'lobbies-frame-container'; const lobbiesCloseContainerId = 'lobbies-close-container'; const lobbiesLoaderContainerId = 'lobbies-loader-container'; const lobbiesTextsContainerId = 'lobbies-texts-container'; const lobbiesIframeContainerId = 'lobbies-iframe-container'; const lobbiesIframeId = 'lobbies-iframe'; let canLobbyBeClosed = true; const notificationContainerIds: string[] = []; export const getDomElementContainer = ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement | null => { // Find the DOM element container. const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { logger.error('No DOM element container found.'); return null; } return domElementContainer; }; export const getLobbiesRootContainer = ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement | null => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return null; } // Find the root container with its ID. const lobbiesRootContainer = domElementContainer.querySelector( `#${lobbiesRootContainerId}` ) as HTMLDivElement | null; return lobbiesRootContainer; }; export const getLobbiesLoaderContainer = ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement | null => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return null; } // Find the loader container with its ID. const lobbiesLoaderContainer = domElementContainer.querySelector( `#${lobbiesLoaderContainerId}` ) as HTMLDivElement | null; return lobbiesLoaderContainer; }; export const getLobbiesIframeContainer = ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement | null => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return null; } // Find the iframe container with its ID. const lobbiesIframeContainer = domElementContainer.querySelector( `#${lobbiesIframeContainerId}` ) as HTMLDivElement | null; return lobbiesIframeContainer; }; export const getLobbiesCloseContainer = ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement | null => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return null; } // Find the close container with its ID. const lobbiesCloseContainer = domElementContainer.querySelector( `#${lobbiesCloseContainerId}` ) as HTMLDivElement | null; return lobbiesCloseContainer; }; export const getLobbiesTextsContainer = ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement | null => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return null; } // Find the text container with its ID. const lobbiesTextContainer = domElementContainer.querySelector( `#${lobbiesTextsContainerId}` ) as HTMLDivElement | null; return lobbiesTextContainer; }; export const getLobbiesIframe = ( runtimeScene: gdjs.RuntimeScene ): HTMLIFrameElement | null => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return null; } // Find the iframe with its ID. const lobbiesIframe = domElementContainer.querySelector( `#${lobbiesIframeId}` ) as HTMLIFrameElement | null; return lobbiesIframe; }; export const displayLoader = ( runtimeScene: gdjs.RuntimeScene, yesOrNo: boolean ) => { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return; } if (yesOrNo) { const loaderContainer: HTMLDivElement = document.createElement('div'); loaderContainer.id = loaderContainerId; loaderContainer.style.backgroundColor = '#000000'; loaderContainer.style.display = 'flex'; loaderContainer.style.height = '100%'; loaderContainer.style.width = '100%'; loaderContainer.style.justifyContent = 'center'; loaderContainer.style.alignItems = 'center'; loaderContainer.style.position = 'relative'; loaderContainer.style.zIndex = '2'; const loader = document.createElement('img'); loader.setAttribute('width', '50px'); loader.setAttribute( 'src', 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgb3BhY2l0eT0nMC4yNScgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iNCI+PC9jaXJjbGU+CjxwYXRoIG9wYWNpdHk9JzAuNzUnIGZpbGw9IiNGRkZGRkYiIGQ9Ik00IDEyYTggOCAwIDAxOC04VjBDNS4zNzMgMCAwIDUuMzczIDAgMTJoNHptMiA1LjI5MUE3Ljk2MiA3Ljk2MiAwIDAxNCAxMkgwYzAgMy4wNDIgMS4xMzUgNS44MjQgMyA3LjkzOGwzLTIuNjQ3eiI+PC9wYXRoPgo8L3N2Zz4=' ); try { loader.animate( [{ transform: 'rotate(0deg)' }, { transform: 'rotate(359deg)' }], { duration: 3000, iterations: Infinity, } ); } catch { logger.warn('Animation not supported, loader will be fixed.'); } loaderContainer.appendChild(loader); if ( domElementContainer.children && domElementContainer.children.length > 0 ) { domElementContainer.insertBefore( loaderContainer, domElementContainer.children[0] ); } else { domElementContainer.appendChild(loaderContainer); } } else { const loaderContainer = domElementContainer.querySelector( `#${loaderContainerId}` ); if (!loaderContainer) return; try { domElementContainer.removeChild(loaderContainer); } catch {} } }; /** * Creates a DOM element that will contain the loader or a message if the game is not registered, * and adds it to the dom container. */ export const displayLobbies = function ( runtimeScene: gdjs.RuntimeScene, onCloseLobbiesContainer: () => void ) { const domElementContainer = getDomElementContainer(runtimeScene); if (!domElementContainer) { return; } const rootContainer = document.createElement('div'); rootContainer.id = lobbiesRootContainerId; rootContainer.style.position = 'relative'; rootContainer.style.backgroundColor = 'rgba(14, 6, 45, 0.5)'; rootContainer.style.opacity = '1'; rootContainer.style.width = '100%'; rootContainer.style.height = '100%'; rootContainer.style.zIndex = '2'; rootContainer.style.pointerEvents = 'all'; const frameContainer = document.createElement('div'); frameContainer.id = lobbiesFrameContainerId; frameContainer.style.backgroundColor = '#FFFFFF'; frameContainer.style.position = 'absolute'; frameContainer.style.top = '16px'; frameContainer.style.bottom = '16px'; frameContainer.style.left = '16px'; frameContainer.style.right = '16px'; frameContainer.style.borderRadius = '8px'; frameContainer.style.boxShadow = '0px 4px 4px rgba(0, 0, 0, 0.25)'; frameContainer.style.overflow = 'hidden'; const _closeContainer: HTMLDivElement = document.createElement('div'); _closeContainer.id = lobbiesCloseContainerId; _closeContainer.style.cursor = 'pointer'; _closeContainer.style.display = 'flex'; _closeContainer.style.justifyContent = 'right'; _closeContainer.style.alignItems = 'center'; _closeContainer.style.zIndex = '3'; _closeContainer.style.position = 'absolute'; _closeContainer.style.top = '32px'; _closeContainer.style.right = '32px'; addTouchAndClickEventListeners(_closeContainer, onCloseLobbiesContainer); const _close = document.createElement('img'); _close.setAttribute('width', '15px'); _close.setAttribute( 'src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI4IiB2aWV3Qm94PSIwIDAgOCA4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTcuODUzNTUgMC4xNDY0NDdDOC4wNDg4MiAwLjM0MTcwOSA4LjA0ODgyIDAuNjU4MjkxIDcuODUzNTUgMC44NTM1NTNMMC44NTM1NTMgNy44NTM1NUMwLjY1ODI5MSA4LjA0ODgyIDAuMzQxNzA5IDguMDQ4ODIgMC4xNDY0NDcgNy44NTM1NUMtMC4wNDg4MTU1IDcuNjU4MjkgLTAuMDQ4ODE1NSA3LjM0MTcxIDAuMTQ2NDQ3IDcuMTQ2NDVMNy4xNDY0NSAwLjE0NjQ0N0M3LjM0MTcxIC0wLjA0ODgxNTUgNy42NTgyOSAtMC4wNDg4MTU1IDcuODUzNTUgMC4xNDY0NDdaIiBmaWxsPSIjMUQxRDI2Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMC4xNDY0NDcgMC4xNDY0NDdDMC4zNDE3MDkgLTAuMDQ4ODE1NSAwLjY1ODI5MSAtMC4wNDg4MTU1IDAuODUzNTUzIDAuMTQ2NDQ3TDcuODUzNTUgNy4xNDY0NUM4LjA0ODgyIDcuMzQxNzEgOC4wNDg4MiA3LjY1ODI5IDcuODUzNTUgNy44NTM1NUM3LjY1ODI5IDguMDQ4ODIgNy4zNDE3MSA4LjA0ODgyIDcuMTQ2NDUgNy44NTM1NUwwLjE0NjQ0NyAwLjg1MzU1M0MtMC4wNDg4MTU1IDAuNjU4MjkxIC0wLjA0ODgxNTUgMC4zNDE3MDkgMC4xNDY0NDcgMC4xNDY0NDdaIiBmaWxsPSIjMUQxRDI2Ii8+Cjwvc3ZnPgo=' ); _closeContainer.appendChild(_close); if (!canLobbyBeClosed) { _closeContainer.style.visibility = 'hidden'; } const loaderContainer: HTMLDivElement = document.createElement('div'); loaderContainer.id = lobbiesLoaderContainerId; loaderContainer.style.display = 'flex'; loaderContainer.style.flexDirection = 'column'; loaderContainer.style.height = '100%'; loaderContainer.style.width = '100%'; loaderContainer.style.justifyContent = 'center'; loaderContainer.style.alignItems = 'center'; const _loader = document.createElement('img'); _loader.setAttribute('width', '28px'); _loader.setAttribute( 'src', 'data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iYW5pbWF0ZS1zcGluIC1tbC0xIG1yLTMgaC01IHctNSB0ZXh0LXdoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgb3BhY2l0eT0nMC4yNScgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSI0Ij48L2NpcmNsZT4KPHBhdGggb3BhY2l0eT0nMC43NScgZmlsbD0iY3VycmVudENvbG9yIiBkPSJNNCAxMmE4IDggMCAwMTgtOFYwQzUuMzczIDAgMCA1LjM3MyAwIDEyaDR6bTIgNS4yOTFBNy45NjIgNy45NjIgMCAwMTQgMTJIMGMwIDMuMDQyIDEuMTM1IDUuODI0IDMgNy45MzhsMy0yLjY0N3oiPjwvcGF0aD4KPC9zdmc+' ); _loader.style.marginTop = '50px'; try { _loader.animate( [{ transform: 'rotate(0deg)' }, { transform: 'rotate(359deg)' }], { duration: 3000, iterations: Infinity, } ); } catch { logger.warn('Animation not supported, loader will be fixed.'); } loaderContainer.appendChild(_loader); const iframeContainer: HTMLDivElement = document.createElement('div'); iframeContainer.id = lobbiesIframeContainerId; iframeContainer.style.display = 'flex'; iframeContainer.style.flexDirection = 'column'; iframeContainer.style.height = '100%'; iframeContainer.style.width = '100%'; iframeContainer.style.justifyContent = 'stretch'; iframeContainer.style.alignItems = 'stretch'; iframeContainer.style.display = 'none'; frameContainer.appendChild(_closeContainer); frameContainer.appendChild(loaderContainer); frameContainer.appendChild(iframeContainer); rootContainer.appendChild(frameContainer); // Display the lobbies window right away, to show a loader // while the call for game registration is happening. domElementContainer.appendChild(rootContainer); }; export const displayIframeInsideLobbiesContainer = ( runtimeScene: gdjs.RuntimeScene, url: string ) => { const iframeContainer = getLobbiesIframeContainer(runtimeScene); const loaderContainer = getLobbiesLoaderContainer(runtimeScene); const textsContainer = getLobbiesTextsContainer(runtimeScene); if (!iframeContainer || !loaderContainer || !textsContainer) { logger.error('Lobbies containers not found.'); return; } const iframe = document.createElement('iframe'); iframe.id = lobbiesIframeId; iframe.setAttribute( 'sandbox', 'allow-forms allow-modals allow-orientation-lock allow-popups allow-same-origin allow-scripts' ); iframe.addEventListener('load', () => { iframeContainer.style.display = 'flex'; loaderContainer.style.display = 'none'; }); iframe.addEventListener('loaderror', () => { addReloadUrlToTextsContainer(() => { // Try again. iframeContainer.removeChild(iframe); iframeContainer.style.display = 'none'; loaderContainer.style.display = 'flex'; displayIframeInsideLobbiesContainer(runtimeScene, url); }, textsContainer); }); iframe.src = url; iframe.style.flex = '1'; iframe.style.border = '0'; iframeContainer.appendChild(iframe); }; /** * Helper to add the texts to the container * especially if the game is not registered. */ export const addTextsToLoadingContainer = ( runtimeScene: gdjs.RuntimeScene, isGameRegistered: boolean, wikiOpenAction: () => void ) => { const loaderContainer = getLobbiesLoaderContainer(runtimeScene); if (!loaderContainer) { logger.error('Loader container not found.'); return; } const textsContainer: HTMLDivElement = document.createElement('div'); textsContainer.id = lobbiesTextsContainerId; textsContainer.style.display = 'flex'; textsContainer.style.flexDirection = 'column'; textsContainer.style.width = '100%'; textsContainer.style.justifyContent = 'center'; textsContainer.style.alignItems = 'center'; textsContainer.style.position = 'relative'; textsContainer.style.zIndex = '3'; textsContainer.style.fontSize = '11pt'; textsContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; if (!isGameRegistered) { const title = document.createElement('h1'); title.innerText = 'Publish your game!'; title.style.fontSize = '20pt'; title.style.fontWeight = 'bold'; const text1 = document.createElement('p'); text1.innerText = "GDevelop's lobbies are only available for published games."; const text2 = document.createElement('p'); text2.innerText = 'Click the button below to learn how to publish your game then try again.'; textsContainer.appendChild(title); textsContainer.appendChild(text1); textsContainer.appendChild(text2); // Remove the loader and add the wiki link. loaderContainer.innerHTML = ''; const link = document.createElement('a'); addTouchAndClickEventListeners(link, wikiOpenAction); link.innerText = 'How to publish my game'; link.style.color = '#0078d4'; link.style.textDecoration = 'none'; link.style.textDecoration = 'underline'; link.style.cursor = 'pointer'; textsContainer.appendChild(link); } loaderContainer.prepend(textsContainer); }; /** * Helper to add a reload link in case the window hasn't opened properly. */ export const addReloadUrlToTextsContainer = ( onClick: () => void, textContainer: HTMLDivElement ) => { const link = document.createElement('a'); addTouchAndClickEventListeners(link, onClick); link.innerText = 'Try again'; link.style.color = '#0078d4'; link.style.textDecoration = 'none'; link.style.textDecoration = 'underline'; link.style.cursor = 'pointer'; textContainer.appendChild(link); }; /** * Hides the lobbies container. */ export const removeLobbiesContainer = function ( runtimeScene: gdjs.RuntimeScene ) { const rootContainer = getLobbiesRootContainer(runtimeScene); if (!rootContainer) { return; } rootContainer.remove(); }; export const changeLobbiesWindowCloseActionVisibility = function ( runtimeScene: gdjs.RuntimeScene, canClose: boolean ) { canLobbyBeClosed = canClose; const closeContainer = getLobbiesCloseContainer(runtimeScene); if (!closeContainer) { return; } closeContainer.style.visibility = canClose ? 'inherit' : 'hidden'; }; export const hideLobbiesCloseButtonTemporarily = function ( runtimeScene: gdjs.RuntimeScene ) { if (!canLobbyBeClosed) return; const closeContainer = getLobbiesCloseContainer(runtimeScene); if (!closeContainer) { return; } closeContainer.style.visibility = 'hidden'; // There is a risk a player leaves the lobby before the end of the countdown, // so we show the close container again after 5 seconds in case this happens, // to allow the player to leave the lobby. setTimeout(() => { closeContainer.style.visibility = 'inherit'; }, 10000); }; /** * Create, display, and hide an error notification. */ export const displayErrorNotification = function ( runtimeScene: gdjs.RuntimeScene ) { showNotification({ runtimeScene, content: 'An error occurred while displaying the game lobbies, please try again.', type: 'error', }); }; /** * Create, display, and hide a notification when a player leaves the game. */ export const displayPlayerLeftNotification = function ( runtimeScene: gdjs.RuntimeScene, playerName: string ) { showNotification({ runtimeScene, content: `${playerName} left.`, type: 'warning', }); }; /** * Create, display, and hide a notification when a player joins the game. */ export const displayPlayerJoinedNotification = function ( runtimeScene: gdjs.RuntimeScene, playerName: string ) { showNotification({ runtimeScene, content: `${playerName} joined.`, type: 'success', }); }; /** * Create, display, and hide a notification when an error happens on connection. */ export const displayConnectionErrorNotification = function ( runtimeScene: gdjs.RuntimeScene ) { showNotification({ runtimeScene, content: 'Could not connect to other players.', type: 'error', }); }; /** * Create, display, and hide a notification when a player leaves the game. */ export const displayHostMigrationNotification = function ( runtimeScene: gdjs.RuntimeScene ) { showNotification({ runtimeScene, content: `Migrating host...`, type: 'warning', id: 'migrating-host', persist: true, }); }; export const showHostMigrationFinishedNotification = function ( runtimeScene: gdjs.RuntimeScene ) { removeNotificationAndShiftOthers('migrating-host'); showNotification({ runtimeScene, content: `Host migrated!`, type: 'success', }); }; export const showHostMigrationFailedNotification = function ( runtimeScene: gdjs.RuntimeScene ) { removeNotificationAndShiftOthers('migrating-host'); showNotification({ runtimeScene, content: `Host migration failed.`, type: 'error', }); }; const removeNotificationAndShiftOthers = function ( notificationContainerId: string ) { const notification = document.getElementById(notificationContainerId); if (!notification) { logger.warn( `Notification ${notificationContainerId} not found. skipping` ); return; } const index = notificationContainerIds.indexOf(notificationContainerId); if (index !== -1) { notificationContainerIds.splice(index, 1); } notification.remove(); // Shift the notifications that are below the one that was removed up. for (let i = index; 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, content, type, id, persist, }: { runtimeScene: gdjs.RuntimeScene; content: string; type: 'success' | 'warning' | 'error'; id?: string; persist?: boolean; }) { // 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 notificationId = id || `notification-${Math.random().toString(36).substring(7)}`; const domContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domContainer) { logger.error('No DOM element container found.'); return; } const divContainer = document.createElement('div'); divContainer.id = notificationId; divContainer.style.position = 'absolute'; divContainer.style.pointerEvents = 'all'; divContainer.style.backgroundColor = type === 'success' ? '#0E062D' : type === 'warning' ? '#FFA500' : '#FF0000'; // 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. divContainer.style.zIndex = '1'; divContainer.style.display = 'flex'; divContainer.style.flexDirection = 'row-reverse'; divContainer.style.justifyContent = 'space-between'; divContainer.style.alignItems = 'center'; divContainer.style.boxShadow = '0px 4px 4px rgba(0, 0, 0, 0.25)'; divContainer.style.borderRadius = '4px'; divContainer.style.fontSize = '11pt'; divContainer.style.color = '#FFFFFF'; divContainer.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"'; try { divContainer.animate( [ { transform: 'translateY(-30px)', opacity: 0 }, { transform: 'translateY(0px)', opacity: 1 }, ], { duration: 700, easing: 'ease-out', } ); } catch { logger.warn('Animation not supported, div will be fixed.'); } const loggedText = document.createElement('p'); loggedText.id = 'loggedText'; loggedText.innerHTML = content; loggedText.style.margin = '0px'; divContainer.appendChild(loggedText); domContainer.appendChild(divContainer); notificationContainerIds.push(notificationId); if (persist) { return; } const animationTime = 700; const notificationTime = 3000; setTimeout(() => { try { divContainer.animate( [ { transform: 'translateY(0px)', opacity: 1 }, { transform: 'translateY(-30px)', opacity: 0 }, ], { duration: animationTime, easing: 'ease-in', } ); } catch { logger.warn('Animation not supported, div will be fixed.'); } }, notificationTime); // Use timeout because onanimationend listener does not work. setTimeout(() => { removeNotificationAndShiftOthers(notificationId); }, notificationTime + animationTime); }; /** * Helper to add event listeners on a pressable/clickable element * to work on both desktop and mobile. */ const addTouchAndClickEventListeners = function ( element: HTMLElement, action: () => void ) { // Touch start event listener for mobile. element.addEventListener('touchstart', (event) => { action(); }); // Click event listener for desktop. element.addEventListener('click', (event) => { action(); }); }; } }