Files
GDevelop/Extensions/Multiplayer/multiplayercomponents.ts

718 lines
25 KiB
TypeScript

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