mirror of
https://github.com/4ian/GDevelop.git
synced 2026-10-12 02:01:17 +00:00
Create GDevelop Authentication extension
* This is an experimental extension! * It allows you to provide a login/register form to your players, with 1 action * It connects the player automatically when they launch your game again * It also provides a new action to submit a leaderboard entry without having to enter a username * This is the beginning of Player Authentication and more features will come allowing creators to interact with their players
This commit is contained in:
@@ -34,7 +34,7 @@ module.exports = {
|
||||
'Open source (MIT License)'
|
||||
)
|
||||
.setExtensionHelpPath('/all-features/leaderboards')
|
||||
.setCategory('Leaderboards')
|
||||
.setCategory('Players')
|
||||
.addInstructionOrExpressionGroupMetadata(_('Leaderboards (experimental)'))
|
||||
.setIcon('JsPlatform/Extensions/leaderboard.svg');
|
||||
|
||||
@@ -65,6 +65,32 @@ module.exports = {
|
||||
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'SaveConnectedPlayerScore',
|
||||
_('Save connected player score'),
|
||||
_("Save the connected player's score to the given leaderboard."),
|
||||
_(
|
||||
'Send to leaderboard _PARAM1_ the score _PARAM2_ for the connected player'
|
||||
),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', false)
|
||||
.addParameter(
|
||||
'expression',
|
||||
_('Score to register for the player'),
|
||||
'',
|
||||
false
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/sha256.js')
|
||||
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.saveConnectedPlayerScore');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasLastSaveErrored',
|
||||
|
||||
@@ -16,8 +16,10 @@ namespace gdjs {
|
||||
lastScoreSavingSucceededAt: number | null;
|
||||
currentlySavingScore: number | null;
|
||||
currentlySavingPlayerName: string | null;
|
||||
currentlySavingPlayerId: string | null;
|
||||
lastSavedScore: number | null;
|
||||
lastSavedPlayerName: string | null;
|
||||
lastSavedPlayerId: string | null;
|
||||
lastSaveError: string | null;
|
||||
isScoreSaving: boolean;
|
||||
hasScoreBeenSaved: boolean;
|
||||
@@ -28,25 +30,45 @@ namespace gdjs {
|
||||
this.lastScoreSavingSucceededAt = null;
|
||||
this.currentlySavingScore = null;
|
||||
this.currentlySavingPlayerName = null;
|
||||
this.currentlySavingPlayerId = null;
|
||||
this.lastSavedScore = null;
|
||||
this.lastSavedPlayerName = null;
|
||||
this.lastSavedPlayerId = null;
|
||||
this.lastSaveError = null;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
}
|
||||
|
||||
isSameAsLastScore(playerName: string, score: number): boolean {
|
||||
isSameAsLastScore({
|
||||
playerName,
|
||||
playerId,
|
||||
score,
|
||||
}: {
|
||||
playerName?: string;
|
||||
playerId?: string;
|
||||
score: number;
|
||||
}): boolean {
|
||||
return (
|
||||
this.lastSavedPlayerName === playerName &&
|
||||
((!!playerName && this.lastSavedPlayerName === playerName) ||
|
||||
(!!playerId && this.lastSavedPlayerId === playerId)) &&
|
||||
this.lastSavedScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isAlreadySavingThisScore(playerName: string, score: number): boolean {
|
||||
isAlreadySavingThisScore({
|
||||
playerName,
|
||||
playerId,
|
||||
score,
|
||||
}: {
|
||||
playerName?: string;
|
||||
playerId?: string;
|
||||
score: number;
|
||||
}): boolean {
|
||||
return (
|
||||
((!!playerName && this.currentlySavingPlayerName === playerName) ||
|
||||
(!!playerId && this.currentlySavingPlayerId === playerId)) &&
|
||||
this.isScoreSaving &&
|
||||
this.currentlySavingPlayerName === playerName &&
|
||||
this.currentlySavingScore === score
|
||||
);
|
||||
}
|
||||
@@ -58,19 +80,29 @@ namespace gdjs {
|
||||
);
|
||||
}
|
||||
|
||||
startSaving(playerName: string, score: number): void {
|
||||
startSaving({
|
||||
playerName,
|
||||
playerId,
|
||||
score,
|
||||
}: {
|
||||
playerName?: string;
|
||||
playerId?: string;
|
||||
score: number;
|
||||
}): void {
|
||||
this.lastScoreSavingStartedAt = Date.now();
|
||||
this.isScoreSaving = true;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
this.currentlySavingScore = score;
|
||||
this.currentlySavingPlayerName = playerName;
|
||||
if (playerName) this.currentlySavingPlayerName = playerName;
|
||||
if (playerId) this.currentlySavingPlayerId = playerId;
|
||||
}
|
||||
|
||||
closeSaving(): void {
|
||||
this.lastScoreSavingSucceededAt = Date.now();
|
||||
this.lastSavedScore = this.currentlySavingScore;
|
||||
this.lastSavedPlayerName = this.currentlySavingPlayerName;
|
||||
this.lastSavedPlayerId = this.currentlySavingPlayerId;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = true;
|
||||
}
|
||||
@@ -155,50 +187,26 @@ namespace gdjs {
|
||||
return lastScoreSavingState;
|
||||
};
|
||||
|
||||
export const savePlayerScore = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float,
|
||||
playerName: string
|
||||
) {
|
||||
let scoreSavingState: ScoreSavingState;
|
||||
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
|
||||
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
if (scoreSavingState.isAlreadySavingThisScore(playerName, score)) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player name and this score. Ignoring this one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isSameAsLastScore(playerName, score)) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'SAME_AS_PREVIOUS';
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'TOO_FAST';
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
|
||||
scoreSavingState.startSaving(playerName, score);
|
||||
|
||||
const baseUrl = 'https://api.gdevelop-app.com/play';
|
||||
const saveScore = function ({
|
||||
leaderboardId,
|
||||
playerName,
|
||||
playerId,
|
||||
playerToken,
|
||||
score,
|
||||
scoreSavingState,
|
||||
runtimeScene,
|
||||
}: {
|
||||
leaderboardId: string;
|
||||
playerName?: string;
|
||||
playerId?: string;
|
||||
playerToken?: string;
|
||||
score: number;
|
||||
scoreSavingState: ScoreSavingState;
|
||||
runtimeScene: gdjs.RuntimeScene;
|
||||
}) {
|
||||
const baseUrl = 'https://api.gdevelop.io/play';
|
||||
const game = runtimeScene.getGame();
|
||||
const payload = JSON.stringify({
|
||||
playerName: formatPlayerName(playerName),
|
||||
const payloadObject = {
|
||||
score: score,
|
||||
sessionId: game.getSessionId(),
|
||||
clientPlayerId: game.getPlayerId(),
|
||||
@@ -206,18 +214,25 @@ namespace gdjs {
|
||||
typeof window !== 'undefined' && (window as any).location
|
||||
? (window as any).location.href
|
||||
: '',
|
||||
});
|
||||
fetch(
|
||||
`${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`,
|
||||
{
|
||||
body: payload,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Digest: computeDigest(payload),
|
||||
},
|
||||
}
|
||||
).then(
|
||||
};
|
||||
if (playerName)
|
||||
payloadObject['playerName'] = formatPlayerName(playerName);
|
||||
const payload = JSON.stringify(payloadObject);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Digest: computeDigest(payload),
|
||||
};
|
||||
if (playerToken)
|
||||
headers['Authorization'] = `player-game-token ${playerToken}`;
|
||||
let leaderboardEntryCreationUrl = `${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`;
|
||||
if (playerId) {
|
||||
leaderboardEntryCreationUrl += `?playerId=${playerId}`;
|
||||
}
|
||||
fetch(leaderboardEntryCreationUrl, {
|
||||
body: payload,
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
}).then(
|
||||
(response) => {
|
||||
if (!response.ok) {
|
||||
const errorCode = response.status.toString();
|
||||
@@ -250,6 +265,114 @@ namespace gdjs {
|
||||
);
|
||||
};
|
||||
|
||||
export const savePlayerScore = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float,
|
||||
playerName: string
|
||||
) {
|
||||
let scoreSavingState: ScoreSavingState;
|
||||
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
|
||||
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
if (
|
||||
scoreSavingState.isAlreadySavingThisScore({ playerName, score })
|
||||
) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player name and this score. Ignoring this one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isSameAsLastScore({ playerName, score })) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'SAME_AS_PREVIOUS';
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'TOO_FAST';
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
|
||||
scoreSavingState.startSaving({ playerName, score });
|
||||
|
||||
saveScore({
|
||||
leaderboardId,
|
||||
playerName,
|
||||
score,
|
||||
scoreSavingState,
|
||||
runtimeScene,
|
||||
});
|
||||
};
|
||||
|
||||
export const saveConnectedPlayerScore = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float
|
||||
) {
|
||||
let scoreSavingState: ScoreSavingState;
|
||||
const playerId = gdjs.playerAuthentication.getUserId();
|
||||
const playerToken = gdjs.playerAuthentication.getUserToken();
|
||||
if (!playerId || !playerToken) {
|
||||
logger.warn(
|
||||
'Cannot save a score for a connected player if the player is not connected.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
|
||||
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
if (scoreSavingState.isAlreadySavingThisScore({ playerId, score })) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player ID and this score. Ignoring this one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isSameAsLastScore({ playerId, score })) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'SAME_AS_PREVIOUS';
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'TOO_FAST';
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
|
||||
scoreSavingState.startSaving({ playerId, score });
|
||||
|
||||
saveScore({
|
||||
leaderboardId,
|
||||
playerId,
|
||||
playerToken,
|
||||
score,
|
||||
scoreSavingState,
|
||||
runtimeScene,
|
||||
});
|
||||
};
|
||||
|
||||
export const isSaving = function (leaderboardId?: string): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// @flow
|
||||
/**
|
||||
* This is a declaration of an extension for GDevelop 5.
|
||||
*
|
||||
* ℹ️ Changes in this file are watched and automatically imported if the editor
|
||||
* is running. You can also manually run `node import-GDJS-Runtime.js` (in newIDE/app/scripts).
|
||||
*
|
||||
* The file must be named "JsExtension.js", otherwise GDevelop won't load it.
|
||||
* ⚠️ If you make a change and the extension is not loaded, open the developer console
|
||||
* and search for any errors.
|
||||
*
|
||||
* More information on https://github.com/4ian/GDevelop/blob/master/newIDE/README-extensions.md
|
||||
*/
|
||||
|
||||
/*::
|
||||
// Import types to allow Flow to do static type checking on this file.
|
||||
// Extensions declaration are typed using Flow (like the editor), but the files
|
||||
// for the game engine are checked with TypeScript annotations.
|
||||
import { type ObjectsRenderingService, type ObjectsEditorService } from '../JsExtensionTypes.flow.js'
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
createExtension: function (
|
||||
_ /*: (string) => string */,
|
||||
gd /*: libGDevelop */
|
||||
) {
|
||||
const extension = new gd.PlatformExtension();
|
||||
extension
|
||||
.setExtensionInformation(
|
||||
'PlayerAuthentication',
|
||||
_('Player Authentication (experimental)'),
|
||||
_('Allow your game to authenticate players.'),
|
||||
'Florian Rival',
|
||||
'Open source (MIT License)'
|
||||
)
|
||||
.setExtensionHelpPath('/all-features/player-authentication')
|
||||
.setCategory('Players');
|
||||
extension
|
||||
.addInstructionOrExpressionGroupMetadata(
|
||||
_('Player Authentication (experimental)')
|
||||
)
|
||||
.setIcon('JsPlatform/Extensions/authentication.svg');
|
||||
|
||||
extension
|
||||
.addDependency()
|
||||
.setName('InAppBrowser Cordova plugin')
|
||||
.setDependencyType('cordova')
|
||||
.setExportName('cordova-plugin-inappbrowser');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'DisplayAuthenticationBanner',
|
||||
_('Display authentication banner'),
|
||||
_(
|
||||
'Display an authentication banner at the top of the game screen, for the player to log in.'
|
||||
),
|
||||
_('Display an authentication banner'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.setHelpPath('/all-features/player-authentication')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.displayAuthenticationBanner');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'OpenAuthenticationWindow',
|
||||
_('Open authentication window'),
|
||||
_('Open an authentication window for the player to log in.'),
|
||||
_('Open an authentication window'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.setHelpPath('/all-features/player-authentication')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.openAuthenticationWindow');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsAuthenticationWindowOpen',
|
||||
_('Authentication window is open'),
|
||||
_('Check if the authentication window is open.'),
|
||||
_('Authentication window is open'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.isAuthenticationWindowOpen');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'LogOut',
|
||||
_('Log out the player'),
|
||||
_('Log out the player.'),
|
||||
_('Log out the player'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.setHelpPath('/all-features/player-authentication')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.logout');
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
'Username',
|
||||
_('Username'),
|
||||
_('Get the username of the authenticated player.'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.getUsername');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsPlayerAuthenticated',
|
||||
_('Player is authenticated'),
|
||||
_('Check if the player is authenticated.'),
|
||||
_('Player is authenticated'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.isAuthenticated');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasPlayerLoggedIn',
|
||||
_('Player has logged in'),
|
||||
_('Check if the player has just logged in.'),
|
||||
_('Player has logged in'),
|
||||
'',
|
||||
'JsPlatform/Extensions/authentication.svg',
|
||||
'JsPlatform/Extensions/authentication.svg'
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationcomponents.js'
|
||||
)
|
||||
.addIncludeFile(
|
||||
'Extensions/PlayerAuthentication/playerauthenticationtools.js'
|
||||
)
|
||||
.setFunctionName('gdjs.playerAuthentication.hasLoggedIn');
|
||||
|
||||
return extension;
|
||||
},
|
||||
runExtensionSanityTests: function (
|
||||
gd /*: libGDevelop */,
|
||||
extension /*: gdPlatformExtension*/
|
||||
) {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
namespace gdjs {
|
||||
const logger = new gdjs.Logger('Player Authentication');
|
||||
export namespace playerAuthenticationComponents {
|
||||
/**
|
||||
* Creates a DOM element that will contain the loader or the authentication iframe.
|
||||
*/
|
||||
export const computeAuthenticationContainer = function (
|
||||
onCloseAuthenticationContainer: () => void
|
||||
): HTMLDivElement {
|
||||
const rootContainer = document.createElement('div');
|
||||
rootContainer.id = 'authentication-root-container';
|
||||
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 subContainer = document.createElement('div');
|
||||
subContainer.id = 'authentication-sub-container';
|
||||
subContainer.style.backgroundColor = '#FFFFFF';
|
||||
subContainer.style.position = 'absolute';
|
||||
subContainer.style.top = '16px';
|
||||
subContainer.style.bottom = '16px';
|
||||
subContainer.style.left = '16px';
|
||||
subContainer.style.right = '16px';
|
||||
subContainer.style.borderRadius = '8px';
|
||||
subContainer.style.boxShadow = '0px 4px 4px rgba(0, 0, 0, 0.25)';
|
||||
subContainer.style.padding = '16px';
|
||||
|
||||
const _closeContainer: HTMLDivElement = document.createElement('div');
|
||||
_closeContainer.style.cursor = 'pointer';
|
||||
_closeContainer.style.display = 'flex';
|
||||
_closeContainer.style.justifyContent = 'right';
|
||||
_closeContainer.style.alignItems = 'center';
|
||||
_closeContainer.style.zIndex = '3';
|
||||
addTouchAndClickEventListeners(
|
||||
_closeContainer,
|
||||
onCloseAuthenticationContainer
|
||||
);
|
||||
|
||||
const _close = document.createElement('img');
|
||||
_close.setAttribute('width', '15px');
|
||||
_close.setAttribute(
|
||||
'src',
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI4IiB2aWV3Qm94PSIwIDAgOCA4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTcuODUzNTUgMC4xNDY0NDdDOC4wNDg4MiAwLjM0MTcwOSA4LjA0ODgyIDAuNjU4MjkxIDcuODUzNTUgMC44NTM1NTNMMC44NTM1NTMgNy44NTM1NUMwLjY1ODI5MSA4LjA0ODgyIDAuMzQxNzA5IDguMDQ4ODIgMC4xNDY0NDcgNy44NTM1NUMtMC4wNDg4MTU1IDcuNjU4MjkgLTAuMDQ4ODE1NSA3LjM0MTcxIDAuMTQ2NDQ3IDcuMTQ2NDVMNy4xNDY0NSAwLjE0NjQ0N0M3LjM0MTcxIC0wLjA0ODgxNTUgNy42NTgyOSAtMC4wNDg4MTU1IDcuODUzNTUgMC4xNDY0NDdaIiBmaWxsPSIjMUQxRDI2Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMC4xNDY0NDcgMC4xNDY0NDdDMC4zNDE3MDkgLTAuMDQ4ODE1NSAwLjY1ODI5MSAtMC4wNDg4MTU1IDAuODUzNTUzIDAuMTQ2NDQ3TDcuODUzNTUgNy4xNDY0NUM4LjA0ODgyIDcuMzQxNzEgOC4wNDg4MiA3LjY1ODI5IDcuODUzNTUgNy44NTM1NUM3LjY1ODI5IDguMDQ4ODIgNy4zNDE3MSA4LjA0ODgyIDcuMTQ2NDUgNy44NTM1NUwwLjE0NjQ0NyAwLjg1MzU1M0MtMC4wNDg4MTU1IDAuNjU4MjkxIC0wLjA0ODgxNTUgMC4zNDE3MDkgMC4xNDY0NDcgMC4xNDY0NDdaIiBmaWxsPSIjMUQxRDI2Ii8+Cjwvc3ZnPgo='
|
||||
);
|
||||
_closeContainer.appendChild(_close);
|
||||
|
||||
const loaderContainer: HTMLDivElement = document.createElement('div');
|
||||
loaderContainer.id = 'authentication-container-loader';
|
||||
loaderContainer.style.display = 'flex';
|
||||
loaderContainer.style.flexDirection = 'column';
|
||||
loaderContainer.style.height = '100%';
|
||||
loaderContainer.style.width = '100%';
|
||||
loaderContainer.style.justifyContent = 'center';
|
||||
loaderContainer.style.alignItems = 'center';
|
||||
loaderContainer.style.position = 'relative';
|
||||
loaderContainer.style.zIndex = '3';
|
||||
loaderContainer.style.fontSize = '11pt';
|
||||
loaderContainer.style.fontFamily =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
|
||||
const _loader = document.createElement('img');
|
||||
_loader.setAttribute('width', '28px');
|
||||
_loader.setAttribute(
|
||||
'src',
|
||||
'data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iYW5pbWF0ZS1zcGluIC1tbC0xIG1yLTMgaC01IHctNSB0ZXh0LXdoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgb3BhY2l0eT0nMC4yNScgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSI0Ij48L2NpcmNsZT4KPHBhdGggb3BhY2l0eT0nMC43NScgZmlsbD0iY3VycmVudENvbG9yIiBkPSJNNCAxMmE4IDggMCAwMTgtOFYwQzUuMzczIDAgMCA1LjM3MyAwIDEyaDR6bTIgNS4yOTFBNy45NjIgNy45NjIgMCAwMTQgMTJIMGMwIDMuMDQyIDEuMTM1IDUuODI0IDMgNy45MzhsMy0yLjY0N3oiPjwvcGF0aD4KPC9zdmc+'
|
||||
);
|
||||
try {
|
||||
_loader.animate(
|
||||
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(359deg)' }],
|
||||
{
|
||||
duration: 3000,
|
||||
iterations: Infinity,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
logger.warn('Animation not supported, loader will be fixed.');
|
||||
}
|
||||
const title = document.createElement('h1');
|
||||
title.innerText = 'Logging in...';
|
||||
title.style.fontSize = '20pt';
|
||||
title.style.fontWeight = 'bold';
|
||||
const text1 = document.createElement('p');
|
||||
text1.innerText = "We're opening another page for you to log in. ";
|
||||
const text2 = document.createElement('p');
|
||||
text2.innerText = "It's alright, we'll wait until you're done.";
|
||||
text2.style.marginBottom = '50px';
|
||||
loaderContainer.appendChild(title);
|
||||
loaderContainer.appendChild(text1);
|
||||
loaderContainer.appendChild(text2);
|
||||
loaderContainer.appendChild(_loader);
|
||||
|
||||
subContainer.appendChild(_closeContainer);
|
||||
subContainer.appendChild(loaderContainer);
|
||||
rootContainer.appendChild(subContainer);
|
||||
|
||||
return rootContainer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a DOM element to display a dismissable banner.
|
||||
*/
|
||||
export const computeDismissableBanner = function (
|
||||
onDismissBanner: () => void
|
||||
): HTMLDivElement {
|
||||
const divContainer = document.createElement('div');
|
||||
|
||||
divContainer.id = 'authenticated-banner';
|
||||
divContainer.style.position = 'absolute';
|
||||
divContainer.style.pointerEvents = 'all';
|
||||
divContainer.style.backgroundColor = '#0E062D';
|
||||
divContainer.style.top = '0px';
|
||||
divContainer.style.height = '48px';
|
||||
divContainer.style.left = '0px';
|
||||
divContainer.style.width = '100%';
|
||||
divContainer.style.padding = '6px 16px';
|
||||
// Use zIndex 1 to make sure it is below the authentication iframe or webview.
|
||||
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.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"';
|
||||
|
||||
const _closeContainer: HTMLDivElement = document.createElement('div');
|
||||
_closeContainer.style.cursor = 'pointer';
|
||||
_closeContainer.style.display = 'flex';
|
||||
_closeContainer.style.justifyContent = 'center';
|
||||
_closeContainer.style.alignItems = 'center';
|
||||
_closeContainer.style.zIndex = '3';
|
||||
_closeContainer.style.marginRight = '32px';
|
||||
_closeContainer.style.height = '100%';
|
||||
addTouchAndClickEventListeners(_closeContainer, onDismissBanner);
|
||||
|
||||
const _close = document.createElement('img');
|
||||
_close.setAttribute('width', '30px');
|
||||
_close.setAttribute(
|
||||
'src',
|
||||
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAiIGhlaWdodD0iMzAiIHZpZXdCb3g9IjAgMCAzMCAzMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTIzLjc1IDguMDEyNUwyMS45ODc1IDYuMjVMMTUgMTMuMjM3NUw4LjAxMjUgNi4yNUw2LjI1IDguMDEyNUwxMy4yMzc1IDE1TDYuMjUgMjEuOTg3NUw4LjAxMjUgMjMuNzVMMTUgMTYuNzYyNUwyMS45ODc1IDIzLjc1TDIzLjc1IDIxLjk4NzVMMTYuNzYyNSAxNUwyMy43NSA4LjAxMjVaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K'
|
||||
);
|
||||
|
||||
_closeContainer.appendChild(_close);
|
||||
divContainer.appendChild(_closeContainer);
|
||||
|
||||
return divContainer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a DOM element representing a banner for the user to know which account
|
||||
* they're using and also to allow switching to another account.
|
||||
*/
|
||||
export const computeAuthenticatedBanner = function (
|
||||
onOpenAuthenticationWindow: () => void,
|
||||
onDismissBanner: () => void,
|
||||
username: string | null
|
||||
): HTMLDivElement {
|
||||
const divContainer = computeDismissableBanner(onDismissBanner);
|
||||
|
||||
const playerUsername = username || 'Anonymous';
|
||||
|
||||
const _textContainer: HTMLDivElement = document.createElement('div');
|
||||
const loggedText = document.createElement('p');
|
||||
loggedText.id = 'loggedText';
|
||||
loggedText.innerHTML = `<img style="margin-right:4px" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSI5IiB2aWV3Qm94PSIwIDAgOSA5IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNC4xNjY2NyAwQzEuODY2NjcgMCAwIDEuODY2NjcgMCA0LjE2NjY3QzAgNi40NjY2NyAxLjg2NjY3IDguMzMzMzMgNC4xNjY2NyA4LjMzMzMzQzYuNDY2NjcgOC4zMzMzMyA4LjMzMzMzIDYuNDY2NjcgOC4zMzMzMyA0LjE2NjY3QzguMzMzMzMgMS44NjY2NyA2LjQ2NjY3IDAgNC4xNjY2NyAwWk0zLjMzMzMzIDYuMjVMMS4yNSA0LjE2NjY3TDEuODM3NSAzLjU3OTE3TDMuMzMzMzMgNS4wNzA4M0w2LjQ5NTgzIDEuOTA4MzNMNy4wODMzMyAyLjVMMy4zMzMzMyA2LjI1WiIgZmlsbD0iIzAwQ0M4MyIvPgo8L3N2Zz4K" />
|
||||
Logged as ${playerUsername}`;
|
||||
loggedText.style.margin = '0px';
|
||||
|
||||
const changeAccountText = document.createElement('p');
|
||||
changeAccountText.id = 'changeAccountText';
|
||||
changeAccountText.innerText = `Click here to switch to another account.`;
|
||||
changeAccountText.style.margin = '0px';
|
||||
changeAccountText.style.marginTop = '4px';
|
||||
changeAccountText.style.textDecoration = 'underline';
|
||||
changeAccountText.style.cursor = 'pointer';
|
||||
addTouchAndClickEventListeners(
|
||||
changeAccountText,
|
||||
onOpenAuthenticationWindow
|
||||
);
|
||||
|
||||
_textContainer.appendChild(loggedText);
|
||||
_textContainer.appendChild(changeAccountText);
|
||||
divContainer.appendChild(_textContainer);
|
||||
|
||||
return divContainer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a DOM element representing a banner for the user to know
|
||||
* they are not connected and to allow logging in.
|
||||
*/
|
||||
export const computeNotAuthenticatedBanner = function (
|
||||
onOpenAuthenticationWindow: () => void,
|
||||
onDismissBanner: () => void
|
||||
): HTMLDivElement {
|
||||
const divContainer = computeDismissableBanner(onDismissBanner);
|
||||
|
||||
const _textContainer: HTMLDivElement = document.createElement('div');
|
||||
const loggedText = document.createElement('p');
|
||||
loggedText.id = 'loggedText';
|
||||
loggedText.innerHTML = `You are not authenticated.`;
|
||||
loggedText.style.margin = '0px';
|
||||
|
||||
const changeAccountText = document.createElement('p');
|
||||
changeAccountText.id = 'changeAccountText';
|
||||
changeAccountText.innerText = `Click here to log in.`;
|
||||
changeAccountText.style.margin = '0px';
|
||||
changeAccountText.style.marginTop = '4px';
|
||||
changeAccountText.style.textDecoration = 'underline';
|
||||
changeAccountText.style.cursor = 'pointer';
|
||||
addTouchAndClickEventListeners(
|
||||
changeAccountText,
|
||||
onOpenAuthenticationWindow
|
||||
);
|
||||
|
||||
_textContainer.appendChild(loggedText);
|
||||
_textContainer.appendChild(changeAccountText);
|
||||
divContainer.appendChild(_textContainer);
|
||||
|
||||
return divContainer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, display, and hide the logged in confirmation.
|
||||
*/
|
||||
export const displayLoggedInNotification = function (
|
||||
domContainer: HTMLDivElement,
|
||||
username: string
|
||||
) {
|
||||
showNotification(
|
||||
domContainer,
|
||||
'authenticated-notification',
|
||||
`<img style="margin-right:4px" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSI5IiB2aWV3Qm94PSIwIDAgOSA5IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNC4xNjY2NyAwQzEuODY2NjcgMCAwIDEuODY2NjcgMCA0LjE2NjY3QzAgNi40NjY2NyAxLjg2NjY3IDguMzMzMzMgNC4xNjY2NyA4LjMzMzMzQzYuNDY2NjcgOC4zMzMzMyA4LjMzMzMzIDYuNDY2NjcgOC4zMzMzMyA0LjE2NjY3QzguMzMzMzMgMS44NjY2NyA2LjQ2NjY3IDAgNC4xNjY2NyAwWk0zLjMzMzMzIDYuMjVMMS4yNSA0LjE2NjY3TDEuODM3NSAzLjU3OTE3TDMuMzMzMzMgNS4wNzA4M0w2LjQ5NTgzIDEuOTA4MzNMNy4wODMzMyAyLjVMMy4zMzMzMyA2LjI1WiIgZmlsbD0iIzAwQ0M4MyIvPgo8L3N2Zz4K" />
|
||||
Logged as ${username}`,
|
||||
'success'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, display, and hide an error notification.
|
||||
*/
|
||||
export const displayErrorNotification = function (
|
||||
domContainer: HTMLDivElement
|
||||
) {
|
||||
showNotification(
|
||||
domContainer,
|
||||
'error-notification',
|
||||
'An error occurred while authenticating, please try again.',
|
||||
'error'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to show a notification to the user, that disappears automatically.
|
||||
*/
|
||||
export const showNotification = function (
|
||||
domContainer: HTMLDivElement,
|
||||
id: string,
|
||||
content: string,
|
||||
type: 'success' | 'error'
|
||||
) {
|
||||
const divContainer = document.createElement('div');
|
||||
|
||||
divContainer.id = id;
|
||||
divContainer.style.position = 'absolute';
|
||||
divContainer.style.pointerEvents = 'all';
|
||||
divContainer.style.backgroundColor =
|
||||
type === 'success' ? '#0E062D' : 'red';
|
||||
divContainer.style.top = '12px';
|
||||
divContainer.style.right = '16px';
|
||||
divContainer.style.padding = '6px 32px 6px 6px';
|
||||
// Use zIndex 1 to make sure it is below the authentication iframe or webview.
|
||||
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);
|
||||
const animationTime = 700;
|
||||
const notificationTime = 5000;
|
||||
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(() => {
|
||||
divContainer.remove();
|
||||
}, notificationTime + animationTime);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to add event listeners on a pressable/clickable element
|
||||
* to work on both desktop and mobile.
|
||||
*/
|
||||
export 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();
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
namespace gdjs {
|
||||
declare var cordova: any;
|
||||
|
||||
const logger = new gdjs.Logger('Player Authentication');
|
||||
const authComponents = gdjs.playerAuthenticationComponents;
|
||||
export namespace playerAuthentication {
|
||||
// Authentication information.
|
||||
let _username: string | null = null;
|
||||
let _userId: string | null = null;
|
||||
let _userToken: string | null = null;
|
||||
let _justLoggedIn = false;
|
||||
|
||||
let _checkedLocalStorage: boolean = false;
|
||||
|
||||
// Authentication display
|
||||
let _authenticationWindow: Window | null = null; // For Web.
|
||||
let _authenticationInAppWindow: Window | null = null; // For Cordova.
|
||||
let _authenticationRootContainer: HTMLDivElement | null = null;
|
||||
let _authenticationBanner: HTMLDivElement | null = null;
|
||||
let _authenticationTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
// Communication methods.
|
||||
let _authenticationMessageCallback:
|
||||
| ((event: MessageEvent) => void)
|
||||
| null = null;
|
||||
let _cordovaAuthenticationMessageCallback:
|
||||
| ((event: MessageEvent) => void)
|
||||
| null = null;
|
||||
let _websocket: WebSocket | null = null;
|
||||
|
||||
// Ensure that the condition "just logged in" is valid only for one frame.
|
||||
gdjs.registerRuntimeScenePostEventsCallback(() => {
|
||||
_justLoggedIn = false;
|
||||
});
|
||||
|
||||
const getLocalStorageKey = (gameId: string) =>
|
||||
`${gameId}_authenticatedUser`;
|
||||
const getAuthWindowUrl = ({
|
||||
gameId,
|
||||
connectionId,
|
||||
}: {
|
||||
gameId: string;
|
||||
connectionId?: string;
|
||||
}) =>
|
||||
`https://liluo.io/auth?gameId=${gameId}${
|
||||
connectionId ? `&connectionId=${connectionId}` : ''
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Returns true if a user token is present in the local storage.
|
||||
*/
|
||||
export const isAuthenticated = () => {
|
||||
if (!_checkedLocalStorage) {
|
||||
readAuthenticatedUserFromLocalStorage();
|
||||
}
|
||||
return _userToken !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the user just logged in.
|
||||
* Useful to update username or trigger messages in the game.
|
||||
*/
|
||||
export const hasLoggedIn = () => _justLoggedIn;
|
||||
|
||||
/**
|
||||
* Returns the username from the local storage.
|
||||
*/
|
||||
export const getUsername = () => {
|
||||
if (!_checkedLocalStorage) {
|
||||
readAuthenticatedUserFromLocalStorage();
|
||||
}
|
||||
return _username || '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the user token from the local storage.
|
||||
*/
|
||||
export const getUserToken = () => {
|
||||
if (!_checkedLocalStorage) {
|
||||
readAuthenticatedUserFromLocalStorage();
|
||||
}
|
||||
return _userToken || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the username from the local storage.
|
||||
*/
|
||||
export const getUserId = () => {
|
||||
if (!_checkedLocalStorage) {
|
||||
readAuthenticatedUserFromLocalStorage();
|
||||
}
|
||||
return _userId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the user information from the local storage.
|
||||
*/
|
||||
export const logout = (runtimeScene: RuntimeScene) => {
|
||||
_username = null;
|
||||
_userToken = null;
|
||||
_userId = null;
|
||||
|
||||
const gameId = gdjs.projectData.properties.projectUuid;
|
||||
if (!gameId) {
|
||||
logger.error('Missing game id in project properties.');
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(getLocalStorageKey(gameId));
|
||||
const onDismissBanner = () => {
|
||||
removeAuthenticationBanner(runtimeScene);
|
||||
};
|
||||
const onOpenAuthenticationWindow = () => {
|
||||
openAuthenticationWindow(runtimeScene);
|
||||
};
|
||||
_authenticationBanner = authComponents.computeNotAuthenticatedBanner(
|
||||
onOpenAuthenticationWindow,
|
||||
onDismissBanner
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the user information from the local storage, and store
|
||||
* them in the extension variables.
|
||||
*/
|
||||
const readAuthenticatedUserFromLocalStorage = () => {
|
||||
const gameId = gdjs.projectData.properties.projectUuid;
|
||||
if (!gameId) {
|
||||
logger.error('Missing game id in project properties.');
|
||||
return;
|
||||
}
|
||||
const authenticatedUserStorageItem = window.localStorage.getItem(
|
||||
getLocalStorageKey(gameId)
|
||||
);
|
||||
if (!authenticatedUserStorageItem) {
|
||||
_checkedLocalStorage = true;
|
||||
return;
|
||||
}
|
||||
const authenticatedUser = JSON.parse(authenticatedUserStorageItem);
|
||||
|
||||
_username = authenticatedUser.username;
|
||||
_userId = authenticatedUser.userId;
|
||||
_userToken = authenticatedUser.userToken;
|
||||
_checkedLocalStorage = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to be called on login or error.
|
||||
* Removes all the UI and callbacks.
|
||||
*/
|
||||
const cleanUpAuthWindowAndCallbacks = (runtimeScene: RuntimeScene) => {
|
||||
removeAuthenticationContainer(runtimeScene);
|
||||
clearAuthenticationWindowTimeout();
|
||||
if (_websocket) {
|
||||
_websocket.close();
|
||||
_websocket = null;
|
||||
}
|
||||
// If a new window was opened (web), close it.
|
||||
if (_authenticationWindow) {
|
||||
_authenticationWindow.close();
|
||||
_authenticationWindow = null;
|
||||
}
|
||||
// If an in-app browser was used (cordova), close it.
|
||||
if (_authenticationInAppWindow) {
|
||||
_authenticationInAppWindow.close();
|
||||
_authenticationInAppWindow = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When the websocket receives the authentication result, close all the
|
||||
* authentication windows, display the notification and focus on the game.
|
||||
*/
|
||||
const handleLoggedInEvent = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
userId: string,
|
||||
username: string | null,
|
||||
userToken: string
|
||||
) {
|
||||
if (!username) {
|
||||
logger.warn('The authenticated player does not have a username');
|
||||
}
|
||||
_username = username;
|
||||
_userId = userId;
|
||||
_userToken = userToken;
|
||||
_justLoggedIn = true;
|
||||
|
||||
const gameId = gdjs.projectData.properties.projectUuid;
|
||||
if (!gameId) {
|
||||
logger.error('Missing game id in project properties.');
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(
|
||||
getLocalStorageKey(gameId),
|
||||
JSON.stringify({
|
||||
username: _username,
|
||||
userId: _userId,
|
||||
userToken: _userToken,
|
||||
})
|
||||
);
|
||||
cleanUpAuthWindowAndCallbacks(runtimeScene);
|
||||
removeAuthenticationBanner(runtimeScene);
|
||||
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleAuthenticationError(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
authComponents.displayLoggedInNotification(
|
||||
domElementContainer,
|
||||
_username || 'Anonymous'
|
||||
);
|
||||
focusOnGame(runtimeScene);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the event sent by the authentication window and
|
||||
* display the appropriate banner.
|
||||
*/
|
||||
const receiveMessageFromAuthenticationWindow = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
event: MessageEvent,
|
||||
{ checkOrigin }: { checkOrigin: boolean }
|
||||
) {
|
||||
const allowedOrigin = 'https://liluo.io';
|
||||
|
||||
// Check origin of message.
|
||||
if (checkOrigin && event.origin !== allowedOrigin) {
|
||||
throw new Error(`Unexpected origin: ${event.origin}`);
|
||||
}
|
||||
// Check that message is not malformed.
|
||||
if (!event.data.id) {
|
||||
throw new Error('Malformed message');
|
||||
}
|
||||
|
||||
// Handle message.
|
||||
switch (event.data.id) {
|
||||
case 'authenticationResult': {
|
||||
if (!(event.data.body && event.data.body.token)) {
|
||||
throw new Error('Malformed message.');
|
||||
}
|
||||
|
||||
handleLoggedInEvent(
|
||||
runtimeScene,
|
||||
event.data.body.userId,
|
||||
event.data.body.username,
|
||||
event.data.body.token
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle any error that can occur as part of the authentication process.
|
||||
*/
|
||||
const handleAuthenticationError = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
message: string
|
||||
) {
|
||||
logger.error(message);
|
||||
cleanUpAuthWindowAndCallbacks(runtimeScene);
|
||||
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleAuthenticationError(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
authComponents.displayErrorNotification(domElementContainer);
|
||||
focusOnGame(runtimeScene);
|
||||
};
|
||||
|
||||
/**
|
||||
* If after 5min, no message has been received from the authentication window,
|
||||
* show a notification and remove the authentication container.
|
||||
*/
|
||||
const startAuthenticationWindowTimeout = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
clearAuthenticationWindowTimeout();
|
||||
const time = 12 * 60 * 1000; // 12 minutes, in case the user needs time to authenticate.
|
||||
_authenticationTimeoutId = setTimeout(() => {
|
||||
logger.info(
|
||||
'Authentication window did not send message in time. Closing it.'
|
||||
);
|
||||
cleanUpAuthWindowAndCallbacks(runtimeScene);
|
||||
focusOnGame(runtimeScene);
|
||||
}, time);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the authentication window timeout.
|
||||
* Useful when:
|
||||
* - the authentication succeeded
|
||||
* - the authentication window is closed
|
||||
*/
|
||||
const clearAuthenticationWindowTimeout = () => {
|
||||
if (_authenticationTimeoutId) clearTimeout(_authenticationTimeoutId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Action to display the banner to the user, depending on their authentication status.
|
||||
*/
|
||||
export const displayAuthenticationBanner = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
if (_authenticationBanner) {
|
||||
// Banner already displayed, ensure it's visible.
|
||||
_authenticationBanner.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
if (!_checkedLocalStorage) {
|
||||
readAuthenticatedUserFromLocalStorage();
|
||||
}
|
||||
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleAuthenticationError(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the authentication banner cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const onDismissBanner = () => {
|
||||
removeAuthenticationBanner(runtimeScene);
|
||||
};
|
||||
const onOpenAuthenticationWindow = () => {
|
||||
openAuthenticationWindow(runtimeScene);
|
||||
};
|
||||
// We display the corresponding banner depending on the authentication status.
|
||||
_authenticationBanner = _userToken
|
||||
? authComponents.computeAuthenticatedBanner(
|
||||
onOpenAuthenticationWindow,
|
||||
onDismissBanner,
|
||||
_username
|
||||
)
|
||||
: authComponents.computeNotAuthenticatedBanner(
|
||||
onOpenAuthenticationWindow,
|
||||
onDismissBanner
|
||||
);
|
||||
domElementContainer.appendChild(_authenticationBanner);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to handle authentication window on Electron.
|
||||
* We open a new window, and create a websocket to know when the user is logged in.
|
||||
*/
|
||||
const openAuthenticationWindowForElectron = (runtimeScene, gameId) => {
|
||||
_websocket = new WebSocket('wss://api-ws.gdevelop.io/play');
|
||||
_websocket.onopen = () => {
|
||||
// When socket is open, ask for the connectionId, so that we can open the authentication window.
|
||||
if (_websocket) {
|
||||
_websocket.send(JSON.stringify({ action: 'getConnectionId' }));
|
||||
}
|
||||
};
|
||||
_websocket.onerror = () => {
|
||||
handleAuthenticationError(
|
||||
runtimeScene,
|
||||
'Error while connecting to the authentication server.'
|
||||
);
|
||||
};
|
||||
_websocket.onmessage = (event) => {
|
||||
if (event.data) {
|
||||
const messageContent = JSON.parse(event.data);
|
||||
switch (messageContent.type) {
|
||||
case 'authenticationResult': {
|
||||
console.log('Received player token');
|
||||
const messageData = messageContent.data;
|
||||
handleLoggedInEvent(
|
||||
runtimeScene,
|
||||
messageData.userId,
|
||||
messageData.username,
|
||||
messageData.token
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'connectionId': {
|
||||
const messagegeData = messageContent.data;
|
||||
const connectionId = messagegeData.connectionId;
|
||||
if (!connectionId) {
|
||||
logger.error('No connectionId received');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = getAuthWindowUrl({ gameId, connectionId });
|
||||
|
||||
const electron = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getElectron();
|
||||
electron.shell.openExternal(targetUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to handle authentication window on Cordova.
|
||||
* We open an InAppBrowser window, and listen to messages posted on this window.
|
||||
*/
|
||||
const openAuthenticationWindowForCordova = (runtimeScene, gameId) => {
|
||||
const targetUrl = getAuthWindowUrl({ gameId });
|
||||
|
||||
_authenticationInAppWindow = cordova.InAppBrowser.open(
|
||||
targetUrl,
|
||||
'authentication',
|
||||
'location=yes' // location=yes is important to show the URL bar to the user.
|
||||
);
|
||||
// Listen to messages posted on the authentication window, so that we can
|
||||
// know when the user is authenticated.
|
||||
if (_authenticationInAppWindow) {
|
||||
_cordovaAuthenticationMessageCallback = (event: MessageEvent) => {
|
||||
receiveMessageFromAuthenticationWindow(runtimeScene, event, {
|
||||
checkOrigin: false, // For Cordova we don't check the origin, as the message is read from the InAppBrowser directly.
|
||||
});
|
||||
};
|
||||
_authenticationInAppWindow.addEventListener(
|
||||
'message',
|
||||
_cordovaAuthenticationMessageCallback,
|
||||
true
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to handle authentication window on web.
|
||||
* We open a new window, and listen to messages posted back to the game window.
|
||||
*/
|
||||
const openAuthenticationWindowForWeb = (runtimeScene, gameId) => {
|
||||
// If we're on a browser, open a new window.
|
||||
const targetUrl = getAuthWindowUrl({ gameId });
|
||||
|
||||
// Listen to messages posted by the authentication window, so that we can
|
||||
// know when the user is authenticated.
|
||||
_authenticationMessageCallback = (event: MessageEvent) => {
|
||||
receiveMessageFromAuthenticationWindow(runtimeScene, event, {
|
||||
checkOrigin: true,
|
||||
});
|
||||
};
|
||||
window.addEventListener('message', _authenticationMessageCallback, true);
|
||||
|
||||
const left = screen.width / 2 - 500 / 2;
|
||||
const top = screen.height / 2 - 600 / 2;
|
||||
const windowFeatures = `left=${left},top=${top},width=500,height=600`;
|
||||
_authenticationWindow = window.open(
|
||||
targetUrl,
|
||||
'authentication',
|
||||
windowFeatures
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Action to display the authentication window to the user.
|
||||
*/
|
||||
export const openAuthenticationWindow = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
// If the banner is displayed, hide it, so that it can be shown again if the user closes the window.
|
||||
if (_authenticationBanner) _authenticationBanner.style.opacity = '0';
|
||||
const _gameId = gdjs.projectData.properties.projectUuid;
|
||||
|
||||
startAuthenticationWindowTimeout(runtimeScene);
|
||||
|
||||
// Create the authentication container for the player to wait.
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleAuthenticationError(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the authentication window cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const onAuthenticationContainerDismissed = () => {
|
||||
cleanUpAuthWindowAndCallbacks(runtimeScene);
|
||||
displayAuthenticationBanner(runtimeScene);
|
||||
};
|
||||
const rootContainer = authComponents.computeAuthenticationContainer(
|
||||
onAuthenticationContainerDismissed
|
||||
);
|
||||
_authenticationRootContainer = rootContainer;
|
||||
domElementContainer.appendChild(_authenticationRootContainer);
|
||||
|
||||
// Based on which platform the game is running, we open the authentication window
|
||||
// with a different window, with or without a websocket.
|
||||
const electron = runtimeScene.getGame().getRenderer().getElectron();
|
||||
if (electron) {
|
||||
openAuthenticationWindowForElectron(runtimeScene, _gameId);
|
||||
} else if (typeof cordova !== 'undefined') {
|
||||
openAuthenticationWindowForCordova(runtimeScene, _gameId);
|
||||
} else {
|
||||
openAuthenticationWindowForWeb(runtimeScene, _gameId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Condition to check if the window is open, so that the game can be paused in the background.
|
||||
*/
|
||||
export const isAuthenticationWindowOpen = function (): boolean {
|
||||
return !!_authenticationRootContainer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the container displaying the authentication window and the callback.
|
||||
*/
|
||||
export const removeAuthenticationContainer = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
logger.info(
|
||||
"The div element covering the game couldn't be found, the authentication must be already closed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the authentication root container.
|
||||
if (_authenticationRootContainer) {
|
||||
domElementContainer.removeChild(_authenticationRootContainer);
|
||||
}
|
||||
|
||||
// Remove the authentication callbacks.
|
||||
if (_authenticationMessageCallback) {
|
||||
window.removeEventListener(
|
||||
'message',
|
||||
_authenticationMessageCallback,
|
||||
true
|
||||
);
|
||||
_authenticationMessageCallback = null;
|
||||
// No need to detach the callback from the InAppBrowser, as it's destroyed when the window is closed.
|
||||
_cordovaAuthenticationMessageCallback = null;
|
||||
}
|
||||
|
||||
_authenticationRootContainer = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the banner displaying the authentication status.
|
||||
*/
|
||||
const removeAuthenticationBanner = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
if (!_authenticationBanner) {
|
||||
logger.info(
|
||||
"The authentication banner couldn't be found, the authentication banner must be already closed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
logger.info(
|
||||
"The div element covering the game couldn't be found, the authentication must be already closed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
domElementContainer.removeChild(_authenticationBanner);
|
||||
_authenticationBanner = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus on game canvas to allow user to interact with it.
|
||||
*/
|
||||
const focusOnGame = function (runtimeScene: gdjs.RuntimeScene) {
|
||||
const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas();
|
||||
if (gameCanvas) gameCanvas.focus();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
module.exports = function (config) {
|
||||
const testFiles = ['./Extensions/**/tests/**.spec.js', './GDJS/tests/tests/**/*.js'];
|
||||
const testFiles = [
|
||||
'./Extensions/**/tests/**.spec.js',
|
||||
'./GDJS/tests/tests/**/*.js',
|
||||
];
|
||||
|
||||
const benchmarkFiles = [
|
||||
'./GDJS/tests/benchmarks/init.js',
|
||||
@@ -25,8 +28,7 @@ module.exports = function (config) {
|
||||
},
|
||||
basePath: '../..',
|
||||
proxies: {
|
||||
'/base/tests-utils/':
|
||||
'/base/GDJS/tests/tests-utils/',
|
||||
'/base/tests-utils/': '/base/GDJS/tests/tests-utils/',
|
||||
},
|
||||
files: [
|
||||
'./GDJS/tests/node_modules/expect.js/index.js',
|
||||
@@ -82,7 +84,8 @@ module.exports = function (config) {
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventory.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventorytools.js',
|
||||
{
|
||||
pattern: './newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.wasm',
|
||||
pattern:
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.wasm',
|
||||
watched: true,
|
||||
included: false,
|
||||
served: true,
|
||||
@@ -92,6 +95,8 @@ module.exports = function (config) {
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/physics2runtimebehavior.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Physics2Behavior/physics2tools.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Leaderboards/leaderboardstools.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/PlayerAuthentication/playerauthenticationtools.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/PlayerAuthentication/playerauthenticationcomponents.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject-pixi-renderer.js',
|
||||
'./newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightobstacleruntimebehavior.js',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.55421 16.3457H5.75283H10.9997H11.0308C11.6104 17.9018 13.1322 19 14.8905 19C17.1386 19 19 17.2049 19 14.953C19 13.0339 17.6429 11.441 15.8458 11.0169L15.653 10.6547C14.861 9.16722 13.4178 8.12702 11.733 7.82892C12.4444 7.10695 12.885 6.12164 12.885 5.02698C12.885 2.7839 11.035 1 8.79595 1C6.55685 1 4.70688 2.7839 4.70688 5.02698C4.70688 6.1017 5.13156 7.071 5.82024 7.7892C3.11649 8.14324 1 10.4207 1 13.2058V14.953C1 15.7506 1.65379 16.3457 2.40071 16.3457H4.55421H4.75283H5.55421ZM15.4649 14.7289L15.4649 14.7289C15.4649 14.7289 15.4649 14.7289 15.4649 14.7289Z" fill="white" stroke="#29ABE1" stroke-width="2" />
|
||||
<path d="M2 13.2058V14.953C2 15.1729 2.1803 15.3457 2.40071 15.3457H4.75283V13.3039C4.75283 13.084 4.93313 12.9113 5.15352 12.9113C5.37392 12.9113 5.55421 13.084 5.55421 13.3039V15.3457H10.9997C10.9877 15.2161 10.9797 15.0866 10.9797 14.953C10.9797 12.8799 12.6707 11.1836 14.7703 11.1247C14.001 9.67979 12.4703 8.74137 10.7313 8.74137H6.55597C4.04357 8.74137 2 10.7439 2 13.2058Z" fill="white" />
|
||||
<path d="M18 14.953C18 13.2764 16.6055 11.9061 14.8905 11.9061C13.1755 11.9061 11.7811 13.2764 11.7811 14.953C11.7811 16.6336 13.1755 18 14.8905 18C16.6055 18 18 16.6336 18 14.953ZM16.1648 14.5683L14.8104 15.8954C14.7343 15.97 14.6341 16.0093 14.5259 16.0093C14.4217 16.0093 14.3176 15.97 14.2454 15.8954L13.6163 15.2789C13.46 15.1258 13.46 14.8784 13.6163 14.7253C13.7726 14.5722 14.025 14.5722 14.1813 14.7253L14.5259 15.063L15.5958 14.0146C15.752 13.8615 16.0085 13.8615 16.1648 14.0146C16.321 14.1677 16.321 14.4151 16.1648 14.5683Z" fill="#2B3990" />
|
||||
<path d="M11.885 5.02698C11.885 6.69874 10.502 8.05397 8.79595 8.05397C7.0899 8.05397 5.70688 6.69874 5.70688 5.02698C5.70688 3.35523 7.0899 2 8.79595 2C10.502 2 11.885 3.35523 11.885 5.02698Z" fill="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -89,6 +89,12 @@ const jsExtensions = [
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Leaderboards/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'PlayerAuthentication',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/PlayerAuthentication/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'DialogueTree',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
|
||||
Reference in New Issue
Block a user