mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
23 Commits
feature/fl
...
v5.0.132-b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
860e9d36e4 | ||
![]() |
c8b461cc5f | ||
![]() |
36cdc5720a | ||
![]() |
a7cd53b921 | ||
![]() |
01a25400ff | ||
![]() |
2b484c0cf1 | ||
![]() |
13204e4b53 | ||
![]() |
c6d6466d54 | ||
![]() |
6cf737ac70 | ||
![]() |
5b8cdbbace | ||
![]() |
a7df37a0dc | ||
![]() |
7721f56dde | ||
![]() |
08ada4f595 | ||
![]() |
caa46a2fb0 | ||
![]() |
660cdd753c | ||
![]() |
3b9a1354ee | ||
![]() |
02fce4132a | ||
![]() |
41241d75e1 | ||
![]() |
fc3e73f360 | ||
![]() |
230493039c | ||
![]() |
50326e4d21 | ||
![]() |
cec7960a7d | ||
![]() |
cec901d4c2 |
@@ -190,6 +190,17 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
|
||||
extension.AddDuplicatedCondition("SourisY", "MouseY").SetHidden();
|
||||
extension.AddDuplicatedExpression("SourisY", "MouseY").SetHidden();
|
||||
|
||||
extension
|
||||
.AddCondition("IsMouseInsideCanvas",
|
||||
_("Mouse cursor is inside the window"),
|
||||
_("Check if the mouse cursor is inside the window."),
|
||||
_("The mouse cursor is inside the window"),
|
||||
"",
|
||||
"res/conditions/mouse24.png",
|
||||
"res/conditions/mouse.png")
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.MarkAsAdvanced();
|
||||
|
||||
extension
|
||||
.AddCondition("MouseButtonPressed",
|
||||
_("Mouse button pressed or touch held"),
|
||||
@@ -300,7 +311,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch24.png",
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "");
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.SetHidden();
|
||||
|
||||
extension
|
||||
.AddCondition(
|
||||
@@ -315,8 +327,54 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch24.png",
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.SetHidden();
|
||||
|
||||
extension
|
||||
.AddCondition(
|
||||
"HasAnyTouchStarted",
|
||||
_("A new touch has started"),
|
||||
_("Check if a touch has just started on this frame. The touch identifiers can be "
|
||||
"accessed using StartedTouchId() and StartedTouchCount()."),
|
||||
_("A new touch has started"),
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch24.png",
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "");
|
||||
|
||||
extension
|
||||
.AddExpression(
|
||||
"StartedTouchCount",
|
||||
_("Started touch count"),
|
||||
_("The number of touches that have just started on this frame. The touch identifiers can be "
|
||||
"accessed using StartedTouchId()."),
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "");
|
||||
|
||||
extension
|
||||
.AddExpression(
|
||||
"StartedTouchId",
|
||||
_("Started touch identifier"),
|
||||
_("The identifier of the touch that has just started on this frame. The touch number of touches can be "
|
||||
"accessed using StartedTouchCount()."),
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.AddParameter("expression", _("Touch index"));
|
||||
|
||||
extension
|
||||
.AddCondition(
|
||||
"HasTouchEnded",
|
||||
_("A touch has ended"),
|
||||
_("Check if a touch has ended."),
|
||||
_("The touch with identifier _PARAM1_ has ended"),
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch24.png",
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.AddParameter("expression", _("Touch identifier"));
|
||||
|
||||
extension
|
||||
.AddExpression("MouseWheelDelta",
|
||||
_("Mouse wheel: Displacement"),
|
||||
@@ -331,7 +389,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
|
||||
_("Identifier of the last touch"),
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "");
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.SetHidden();
|
||||
|
||||
extension
|
||||
.AddExpression("LastEndedTouchId",
|
||||
@@ -339,7 +398,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsMouseExtension(
|
||||
_("Identifier of the last ended touch"),
|
||||
_("Multitouch"),
|
||||
"res/conditions/touch.png")
|
||||
.AddCodeOnlyParameter("currentScene", "");
|
||||
.AddCodeOnlyParameter("currentScene", "")
|
||||
.SetHidden();
|
||||
}
|
||||
|
||||
} // namespace gd
|
||||
|
@@ -9,6 +9,7 @@
|
||||
#if defined(GD_IDE_ONLY)
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
#include "GDCore/String.h"
|
||||
namespace gd {
|
||||
class Project;
|
||||
@@ -196,7 +197,8 @@ class GD_CORE_API ParameterMetadata {
|
||||
parameterType == "objectPointName" ||
|
||||
parameterType == "objectAnimationName" ||
|
||||
parameterType == "functionParameterName" ||
|
||||
parameterType == "externalLayoutName";
|
||||
parameterType == "externalLayoutName" ||
|
||||
parameterType == "leaderboardId";
|
||||
} else if (type == "variable") {
|
||||
return parameterType == "objectvar" || parameterType == "globalvar" ||
|
||||
parameterType == "scenevar";
|
||||
|
@@ -66,6 +66,9 @@ Project::Project()
|
||||
projectUuid(""),
|
||||
useDeprecatedZeroAsDefaultZOrder(false),
|
||||
useExternalSourceFiles(false),
|
||||
isPlayableWithKeyboard(false),
|
||||
isPlayableWithGamepad(false),
|
||||
isPlayableWithMobile(false),
|
||||
currentPlatform(NULL),
|
||||
gdMajorVersion(gd::VersionWrapper::Major()),
|
||||
gdMinorVersion(gd::VersionWrapper::Minor()),
|
||||
|
@@ -1,39 +1,40 @@
|
||||
describe('Inventory', function () {
|
||||
var runtimeGame = new gdjs.RuntimeGame({
|
||||
variables: [],
|
||||
properties: { windowWidth: 800, windowHeight: 600 },
|
||||
resources: { resources: [] },
|
||||
it('Inventories can be serialized then unserialized with no data loss', () => {
|
||||
var runtimeGame = new gdjs.RuntimeGame({
|
||||
variables: [],
|
||||
properties: { windowWidth: 800, windowHeight: 600 },
|
||||
resources: { resources: [] },
|
||||
});
|
||||
var runtimeScene = new gdjs.RuntimeScene(runtimeGame);
|
||||
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.equip(runtimeScene, 'MyInventory', 'sword', true);
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'armor');
|
||||
gdjs.evtTools.inventory.setMaximum(runtimeScene, 'MyInventory', 'armor', 1);
|
||||
|
||||
var variable = new gdjs.Variable();
|
||||
gdjs.evtTools.inventory.serializeToVariable(
|
||||
runtimeScene,
|
||||
'MyInventory',
|
||||
variable
|
||||
);
|
||||
gdjs.evtTools.inventory.unserializeFromVariable(
|
||||
runtimeScene,
|
||||
'MyInventory2',
|
||||
variable
|
||||
);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(2);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.isEquipped(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(true);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(1);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(false);
|
||||
});
|
||||
var runtimeScene = new gdjs.RuntimeScene(runtimeGame);
|
||||
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.equip(runtimeScene, 'MyInventory', 'sword', true);
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'armor');
|
||||
gdjs.evtTools.inventory.setMaximum(runtimeScene, 'MyInventory', 'armor', 1);
|
||||
|
||||
var variable = new gdjs.Variable();
|
||||
gdjs.evtTools.inventory.serializeToVariable(
|
||||
runtimeScene,
|
||||
'MyInventory',
|
||||
variable
|
||||
);
|
||||
gdjs.evtTools.inventory.unserializeFromVariable(
|
||||
runtimeScene,
|
||||
'MyInventory2',
|
||||
variable
|
||||
);
|
||||
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(2);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.isEquipped(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(true);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(1);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(false);
|
||||
});
|
||||
|
272
Extensions/Leaderboards/JsExtension.js
Normal file
272
Extensions/Leaderboards/JsExtension.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// @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(
|
||||
'Leaderboards',
|
||||
_('Leaderboards (experimental)'),
|
||||
_('Allow your game to send scores to your leaderboards.'),
|
||||
'Florian Rival',
|
||||
'Open source (MIT License)'
|
||||
)
|
||||
.setExtensionHelpPath('/all-features/leaderboards')
|
||||
.setCategory('Leaderboards')
|
||||
.addInstructionOrExpressionGroupMetadata(_('Leaderboards'))
|
||||
.setIcon('JsPlatform/Extensions/leaderboard.svg');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'SavePlayerScore',
|
||||
_('Save player score'),
|
||||
_("Save the player's score to the given leaderboard."),
|
||||
_(
|
||||
'Send to leaderboard _PARAM1_ the score _PARAM2_ with player name: _PARAM3_.'
|
||||
),
|
||||
_('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
|
||||
)
|
||||
.addParameter('string', _('Name to register for the player'), '', false)
|
||||
.addParameter(
|
||||
'scenevar',
|
||||
_('Variable where to store the saved score (optional)'),
|
||||
'',
|
||||
true
|
||||
)
|
||||
.addParameter(
|
||||
'scenevar',
|
||||
_(
|
||||
'Variable where to store the error message (optional, if an error occurs)'
|
||||
),
|
||||
'',
|
||||
true
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/sha256.js')
|
||||
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasLastSaveErrored',
|
||||
_('Last score save has errored'),
|
||||
_('Check if the last attempt to save a score has errored.'),
|
||||
_('Last score save in leaderboard _PARAM0_ has errored'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.hasSavingErrored');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasLastSaveSucceeded',
|
||||
_('Last score save has succeeded'),
|
||||
_('Check if the last attempt to save a score has succeeded.'),
|
||||
_('Last score save in leaderboard _PARAM0_ has succeeded'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action that successfully ended.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.hasBeenSaved');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsSaving',
|
||||
_('Score is saving'),
|
||||
_('Check if a score is currently being saved in leaderboard.'),
|
||||
_('Score is saving in leaderboard _PARAM0_'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isSaving');
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
'LastSaveError',
|
||||
_('Error of last save attempt'),
|
||||
_('Get the error of the last save attempt.'),
|
||||
_('Error of last save attempt in leaderboard _PARAM0_'),
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
|
||||
)
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.getLastSaveError');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsLeaderboardViewErrored',
|
||||
_('Leaderboard display has errored'),
|
||||
_('Check if the display of the leaderboard errored.'),
|
||||
_('Leaderboard display has errored'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewErrored');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsLeaderboardViewLoaded',
|
||||
_('Leaderboard display has loaded'),
|
||||
_(
|
||||
'Check if the display of the leaderboard has finished loading and been displayed on screen.'
|
||||
),
|
||||
_('Leaderboard display has loaded and is displayed on screen'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewLoaded');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsLeaderboardViewLoading',
|
||||
_('Leaderboard display is loading'),
|
||||
_('Check if the display of the leaderboard is loading.'),
|
||||
_('Leaderboard display is loading'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewLoading');
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
'FormatPlayerName',
|
||||
_('Format player name'),
|
||||
_('Formats a name so that it can be submitted to a leaderboard.'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('string', _('Raw player name'), '', false)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.formatPlayerName');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'DisplayLeaderboard',
|
||||
_('Display leaderboard'),
|
||||
_(
|
||||
'Display the specified leaderboard on top of the game. If a leaderboard was already displayed on top of the game, the new leaderboard will replace it.'
|
||||
),
|
||||
_('Display leaderboard _PARAM1_ (display a loader: _PARAM2_)'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', false)
|
||||
.addParameter(
|
||||
'yesorno',
|
||||
_('Display loader while leaderboard is loading'),
|
||||
'',
|
||||
false
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.displayLeaderboard');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'CloseLeaderboardView',
|
||||
_('Close current leaderboard'),
|
||||
_('Close the leaderboard currently displayed on top of the game.'),
|
||||
_('Close current leaderboard displayed on top of the game'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.closeLeaderboardView');
|
||||
|
||||
return extension;
|
||||
},
|
||||
runExtensionSanityTests: function (
|
||||
gd /*: libGDevelop */,
|
||||
extension /*: gdPlatformExtension*/
|
||||
) {
|
||||
return [];
|
||||
},
|
||||
};
|
653
Extensions/Leaderboards/leaderboardstools.ts
Normal file
653
Extensions/Leaderboards/leaderboardstools.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
/// <reference path="sha256.d.ts" />
|
||||
|
||||
namespace gdjs {
|
||||
const logger = new gdjs.Logger('Leaderboards');
|
||||
export namespace evtTools {
|
||||
export namespace leaderboards {
|
||||
const computeDigest = (payload: string): string => {
|
||||
const shaObj = new jsSHA('SHA-256', 'TEXT', { encoding: 'UTF8' });
|
||||
shaObj.update(payload);
|
||||
return shaObj.getHash('B64');
|
||||
};
|
||||
|
||||
// Score saving
|
||||
class ScoreSavingState {
|
||||
lastScoreSavingStartedAt: number | null;
|
||||
lastScoreSavingSucceededAt: number | null;
|
||||
currentlySavingScore: number | null;
|
||||
currentlySavingPlayerName: string | null;
|
||||
lastSavedScore: number | null;
|
||||
lastSavedPlayerName: string | null;
|
||||
lastSaveError: string | null;
|
||||
isScoreSaving: boolean;
|
||||
hasScoreBeenSaved: boolean;
|
||||
hasScoreSavingErrored: boolean;
|
||||
|
||||
constructor() {
|
||||
this.lastScoreSavingStartedAt = null;
|
||||
this.lastScoreSavingSucceededAt = null;
|
||||
this.currentlySavingScore = null;
|
||||
this.currentlySavingPlayerName = null;
|
||||
this.lastSavedScore = null;
|
||||
this.lastSavedPlayerName = null;
|
||||
this.lastSaveError = null;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
}
|
||||
|
||||
isSameAsLastScore(playerName: string, score: number): boolean {
|
||||
return (
|
||||
this.lastSavedPlayerName === playerName &&
|
||||
this.lastSavedScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isAlreadySavingThisScore(playerName: string, score: number): boolean {
|
||||
return (
|
||||
this.isScoreSaving &&
|
||||
this.currentlySavingPlayerName === playerName &&
|
||||
this.currentlySavingScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isTooSoonToSaveAnotherScore(): boolean {
|
||||
return (
|
||||
!!this.lastScoreSavingSucceededAt &&
|
||||
Date.now() - this.lastScoreSavingSucceededAt < 500
|
||||
);
|
||||
}
|
||||
|
||||
startSaving(playerName: string, score: number): void {
|
||||
this.lastScoreSavingStartedAt = Date.now();
|
||||
this.isScoreSaving = true;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
this.currentlySavingScore = score;
|
||||
this.currentlySavingPlayerName = playerName;
|
||||
}
|
||||
|
||||
closeSaving(): void {
|
||||
this.lastScoreSavingSucceededAt = Date.now();
|
||||
this.lastSavedScore = this.currentlySavingScore;
|
||||
this.lastSavedPlayerName = this.currentlySavingPlayerName;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = true;
|
||||
}
|
||||
|
||||
setError(errorCode: string): void {
|
||||
this.lastSaveError = errorCode;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = true;
|
||||
}
|
||||
}
|
||||
|
||||
let _scoreSavingStateByLeaderboard: {
|
||||
[leaderboardId: string]: ScoreSavingState;
|
||||
} = {};
|
||||
|
||||
// Leaderboard display
|
||||
let _requestedLeaderboardId: string | null;
|
||||
let _leaderboardViewIframe: HTMLIFrameElement | null = null;
|
||||
let _leaderboardViewIframeErrored: boolean = false;
|
||||
let _leaderboardViewIframeLoading: boolean = false;
|
||||
let _leaderboardViewIframeLoaded: boolean = false;
|
||||
let _errorTimeoutId: NodeJS.Timeout | null = null;
|
||||
let _leaderboardViewClosingCallback:
|
||||
| ((event: MessageEvent) => void)
|
||||
| null = null;
|
||||
|
||||
const _loaderContainer: HTMLDivElement = document.createElement('div');
|
||||
_loaderContainer.style.backgroundColor = '#000000';
|
||||
_loaderContainer.style.display = 'flex';
|
||||
_loaderContainer.style.height = '100%';
|
||||
_loaderContainer.style.width = '100%';
|
||||
_loaderContainer.style.justifyContent = 'center';
|
||||
_loaderContainer.style.alignItems = 'center';
|
||||
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);
|
||||
|
||||
const getLastScoreSavingState = function ({
|
||||
hasSucceeded,
|
||||
}: {
|
||||
hasSucceeded: boolean;
|
||||
}): ScoreSavingState | null {
|
||||
const getDateField = (scoreSavingState: ScoreSavingState) =>
|
||||
hasSucceeded
|
||||
? scoreSavingState.lastScoreSavingSucceededAt
|
||||
: scoreSavingState.lastScoreSavingStartedAt;
|
||||
const scoreSavingStates = Object.values(
|
||||
_scoreSavingStateByLeaderboard
|
||||
).filter((scoreSavingState) => !!getDateField(scoreSavingState));
|
||||
if (scoreSavingStates.length === 0) return null;
|
||||
|
||||
let lastScoreSavingState = scoreSavingStates[0];
|
||||
scoreSavingStates.forEach((scoreSavingState) => {
|
||||
const currentItemDate = getDateField(scoreSavingState);
|
||||
const lastItemDate = getDateField(lastScoreSavingState);
|
||||
if (
|
||||
currentItemDate &&
|
||||
lastItemDate &&
|
||||
currentItemDate > lastItemDate
|
||||
) {
|
||||
lastScoreSavingState = scoreSavingState;
|
||||
}
|
||||
});
|
||||
return lastScoreSavingState;
|
||||
};
|
||||
|
||||
export const savePlayerScore = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float,
|
||||
playerName: string,
|
||||
responseVar: gdjs.Variable,
|
||||
errorVar: gdjs.Variable
|
||||
) {
|
||||
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);
|
||||
errorVar.setString(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);
|
||||
errorVar.setString(errorCode);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
|
||||
errorVar.setString('');
|
||||
responseVar.setString('');
|
||||
scoreSavingState.startSaving(playerName, score);
|
||||
|
||||
const baseUrl = 'https://api.gdevelop-app.com/play';
|
||||
const game = runtimeScene.getGame();
|
||||
const payload = JSON.stringify({
|
||||
playerName: formatPlayerName(playerName),
|
||||
score: score,
|
||||
sessionId: game.getSessionId(),
|
||||
clientPlayerId: game.getPlayerId(),
|
||||
location:
|
||||
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(
|
||||
(response) => {
|
||||
if (!response.ok) {
|
||||
const errorCode = response.status.toString();
|
||||
logger.error(
|
||||
'Server responded with an error:',
|
||||
errorCode,
|
||||
response.statusText
|
||||
);
|
||||
scoreSavingState.setError(errorCode);
|
||||
errorVar.setString(errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
scoreSavingState.closeSaving();
|
||||
|
||||
return response.text().then(
|
||||
(text) => {
|
||||
responseVar.setString(text);
|
||||
},
|
||||
(error) => {
|
||||
logger.warn(
|
||||
'An error occurred when reading response but score has been saved:',
|
||||
error
|
||||
);
|
||||
responseVar.setString('CANNOT_READ_RESPONSE');
|
||||
}
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Error while submitting a leaderboard score:', error);
|
||||
const errorCode = 'REQUEST_NOT_SENT';
|
||||
scoreSavingState.setError(errorCode);
|
||||
errorVar.setString(errorCode);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const isSaving = function (leaderboardId?: string): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].isScoreSaving
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.isScoreSaving
|
||||
: false;
|
||||
};
|
||||
|
||||
export const hasBeenSaved = function (leaderboardId?: string): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].hasScoreBeenSaved
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: true,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.hasScoreBeenSaved
|
||||
: false;
|
||||
};
|
||||
|
||||
export const hasSavingErrored = function (
|
||||
leaderboardId?: string
|
||||
): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
.hasScoreSavingErrored
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.hasScoreSavingErrored
|
||||
: false;
|
||||
};
|
||||
|
||||
export const getLastSaveError = function (
|
||||
leaderboardId?: string
|
||||
): string | null {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].lastSaveError
|
||||
: 'NO_DATA_ERROR';
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.lastSaveError
|
||||
: 'NO_DATA_ERROR';
|
||||
};
|
||||
|
||||
export const formatPlayerName = function (rawName: string): string {
|
||||
if (
|
||||
!rawName ||
|
||||
typeof rawName !== 'string' ||
|
||||
(typeof rawName === 'string' && rawName.length === 0)
|
||||
) {
|
||||
return `Player${Math.round(
|
||||
(Math.random() * 9 + 1) * 10000 // Number between 10,000 and 99,999
|
||||
)}`;
|
||||
}
|
||||
return rawName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s/g, '_')
|
||||
.replace(/[^\w|-]/g, '')
|
||||
.slice(0, 30);
|
||||
};
|
||||
|
||||
const checkLeaderboardAvailability = function (
|
||||
url: string
|
||||
): Promise<boolean> {
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(
|
||||
(response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`Error while fetching leaderboard view, server returned: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(err) => {
|
||||
logger.error('Error while fetching leaderboard view:', err);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const receiveMessageFromLeaderboardView = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
displayLoader: boolean,
|
||||
event: MessageEvent
|
||||
) {
|
||||
switch (event.data) {
|
||||
case 'closeLeaderboardView':
|
||||
closeLeaderboardView(runtimeScene);
|
||||
break;
|
||||
case 'leaderboardViewLoaded':
|
||||
if (displayLoader) {
|
||||
if (_errorTimeoutId) clearTimeout(_errorTimeoutId);
|
||||
displayLoaderInLeaderboardView(false, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
}
|
||||
if (!_leaderboardViewIframe) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The leaderboard view couldn't be found. Doing nothing."
|
||||
);
|
||||
return;
|
||||
}
|
||||
_leaderboardViewIframe.style.opacity = '1';
|
||||
_leaderboardViewIframeLoaded = true;
|
||||
_leaderboardViewIframeLoading = false;
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleErrorDisplayingLeaderboard = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
message: string
|
||||
) {
|
||||
logger.error(message);
|
||||
_leaderboardViewIframeErrored = true;
|
||||
_leaderboardViewIframeLoading = false;
|
||||
closeLeaderboardView(runtimeScene);
|
||||
};
|
||||
|
||||
const resetLeaderboardDisplayErrorTimeout = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (_errorTimeoutId) clearTimeout(_errorTimeoutId);
|
||||
_errorTimeoutId = setTimeout(() => {
|
||||
if (!_leaderboardViewIframeLoaded) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'Leaderboard page did not send message in time. Closing leaderboard view.'
|
||||
);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const displayLoaderInLeaderboardView = function (
|
||||
yesOrNo: boolean,
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
options: { callOnErrorIfDomElementContainerMissing: boolean }
|
||||
): boolean {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
if (options.callOnErrorIfDomElementContainerMissing) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (yesOrNo) {
|
||||
if (
|
||||
domElementContainer.children &&
|
||||
domElementContainer.children.length > 0
|
||||
) {
|
||||
domElementContainer.insertBefore(
|
||||
_loaderContainer,
|
||||
domElementContainer.children[0]
|
||||
);
|
||||
} else {
|
||||
domElementContainer.appendChild(_loaderContainer);
|
||||
}
|
||||
if (_leaderboardViewIframe) {
|
||||
_leaderboardViewIframe.style.opacity = '0';
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
domElementContainer.removeChild(_loaderContainer);
|
||||
if (_leaderboardViewIframe) {
|
||||
_leaderboardViewIframe.style.opacity = '1';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const computeLeaderboardDisplayingIframe = function (
|
||||
url: string
|
||||
): HTMLIFrameElement {
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.src = url;
|
||||
iframe.id = 'leaderboard-view';
|
||||
iframe.style.position = 'absolute';
|
||||
// To trigger iframe loading and be able to listen to its events, use `opacity: 0` instead of `visibility: hidden` or `display: none`
|
||||
iframe.style.opacity = '0';
|
||||
iframe.style.pointerEvents = 'all';
|
||||
iframe.style.backgroundColor = '#FFFFFF';
|
||||
iframe.style.top = '0px';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.left = '0px';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.border = 'none';
|
||||
|
||||
return iframe;
|
||||
};
|
||||
|
||||
export const displayLeaderboard = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
displayLoader: boolean
|
||||
) {
|
||||
// First ensure we're not trying to display multiple times the same leaderboard (in which case
|
||||
// we "de-duplicate" the request to display it).
|
||||
if (leaderboardId === _requestedLeaderboardId) {
|
||||
if (_leaderboardViewIframeLoading) {
|
||||
logger.warn(
|
||||
`Already loading the view for the requested loader (${leaderboardId}), ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_leaderboardViewIframeLoaded) {
|
||||
logger.warn(
|
||||
`Already loaded the view for the requested loader (${leaderboardId}), ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// We are now assured we want to display a new (or different) leaderboard: start loading it.
|
||||
_requestedLeaderboardId = leaderboardId;
|
||||
_leaderboardViewIframeErrored = false;
|
||||
_leaderboardViewIframeLoaded = false;
|
||||
_leaderboardViewIframeLoading = true;
|
||||
|
||||
if (displayLoader) {
|
||||
displayLoaderInLeaderboardView(true, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
const gameId = gdjs.projectData.properties.projectUuid;
|
||||
const targetUrl = `https://liluo.io/games/${gameId}/leaderboard/${leaderboardId}?inGameEmbedded=true`;
|
||||
checkLeaderboardAvailability(targetUrl).then(
|
||||
(isAvailable) => {
|
||||
if (leaderboardId !== _requestedLeaderboardId) {
|
||||
logger.warn(
|
||||
`Received a response for leaderboard ${leaderboardId} though the last leaderboard requested is ${_requestedLeaderboardId}, ignoring this response.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isAvailable) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'Leaderboard data could not be fetched. Closing leaderboard view if there is one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_leaderboardViewIframe) {
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
if (displayLoader) {
|
||||
displayLoaderInLeaderboardView(true, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
}
|
||||
_leaderboardViewIframe.src = targetUrl;
|
||||
} else {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
|
||||
_leaderboardViewIframe = computeLeaderboardDisplayingIframe(
|
||||
targetUrl
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
_leaderboardViewClosingCallback = (event: MessageEvent) => {
|
||||
receiveMessageFromLeaderboardView(
|
||||
runtimeScene,
|
||||
displayLoader,
|
||||
event
|
||||
);
|
||||
};
|
||||
(window as any).addEventListener(
|
||||
'message',
|
||||
_leaderboardViewClosingCallback,
|
||||
true
|
||||
);
|
||||
}
|
||||
domElementContainer.appendChild(_leaderboardViewIframe);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
logger.error(err);
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'An error occurred when fetching leaderboard data. Closing leaderboard view if there is one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const isLeaderboardViewErrored = function (): boolean {
|
||||
return _leaderboardViewIframeErrored;
|
||||
};
|
||||
|
||||
export const isLeaderboardViewLoaded = function (): boolean {
|
||||
return _leaderboardViewIframeLoaded;
|
||||
};
|
||||
|
||||
export const isLeaderboardViewLoading = function (): boolean {
|
||||
return _leaderboardViewIframeLoading;
|
||||
};
|
||||
|
||||
export const closeLeaderboardView = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
try {
|
||||
displayLoaderInLeaderboardView(false, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
|
||||
if (!_leaderboardViewIframe) {
|
||||
logger.info(
|
||||
"The iframe displaying the current leaderboard couldn't be found, the leaderboard view 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 leaderboard view must be already closed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).removeEventListener(
|
||||
'message',
|
||||
_leaderboardViewClosingCallback,
|
||||
true
|
||||
);
|
||||
_leaderboardViewClosingCallback = null;
|
||||
}
|
||||
domElementContainer.removeChild(_leaderboardViewIframe);
|
||||
_leaderboardViewIframe = null;
|
||||
} finally {
|
||||
// Don't reset the loading flag (the view of another leaderboard might be loading)
|
||||
// or the error flag (we want to persist the error flag even after the view is closed),
|
||||
// but reset the flag indicating the view is loaded (if it was).
|
||||
_leaderboardViewIframeLoaded = false;
|
||||
|
||||
const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas();
|
||||
if (gameCanvas) gameCanvas.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
234
Extensions/Leaderboards/sha256.d.ts
vendored
Normal file
234
Extensions/Leaderboards/sha256.d.ts
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
declare type EncodingType = 'UTF8' | 'UTF16BE' | 'UTF16LE';
|
||||
declare type FormatNoTextType =
|
||||
| 'HEX'
|
||||
| 'B64'
|
||||
| 'BYTES'
|
||||
| 'ARRAYBUFFER'
|
||||
| 'UINT8ARRAY';
|
||||
declare type FormatType = 'TEXT' | FormatNoTextType;
|
||||
declare type GenericInputType =
|
||||
| {
|
||||
value: string;
|
||||
format: 'TEXT';
|
||||
encoding?: EncodingType;
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
format: 'B64' | 'HEX' | 'BYTES';
|
||||
}
|
||||
| {
|
||||
value: ArrayBuffer;
|
||||
format: 'ARRAYBUFFER';
|
||||
}
|
||||
| {
|
||||
value: Uint8Array;
|
||||
format: 'UINT8ARRAY';
|
||||
};
|
||||
declare type FixedLengthOptionsNoEncodingType =
|
||||
| {
|
||||
hmacKey?: GenericInputType;
|
||||
}
|
||||
| {
|
||||
numRounds?: number;
|
||||
};
|
||||
declare type FixedLengthOptionsEncodingType =
|
||||
| {
|
||||
hmacKey?: GenericInputType;
|
||||
encoding?: EncodingType;
|
||||
}
|
||||
| {
|
||||
numRounds?: number;
|
||||
encoding?: EncodingType;
|
||||
};
|
||||
interface packedValue {
|
||||
value: number[];
|
||||
binLen: number;
|
||||
}
|
||||
|
||||
declare abstract class jsSHABase<StateT, VariantT> {
|
||||
/**
|
||||
* @param variant The desired SHA variant.
|
||||
* @param inputFormat The input format to be used in future `update` calls.
|
||||
* @param options Hashmap of extra input options.
|
||||
*/
|
||||
protected readonly shaVariant: VariantT;
|
||||
protected readonly inputFormat: FormatType;
|
||||
protected readonly utfType: EncodingType;
|
||||
protected readonly numRounds: number;
|
||||
protected abstract intermediateState: StateT;
|
||||
protected keyWithIPad: number[];
|
||||
protected keyWithOPad: number[];
|
||||
protected remainder: number[];
|
||||
protected remainderLen: number;
|
||||
protected updateCalled: boolean;
|
||||
protected processedLen: number;
|
||||
protected macKeySet: boolean;
|
||||
protected abstract readonly variantBlockSize: number;
|
||||
protected abstract readonly bigEndianMod: -1 | 1;
|
||||
protected abstract readonly outputBinLen: number;
|
||||
protected abstract readonly isVariableLen: boolean;
|
||||
protected abstract readonly HMACSupported: boolean;
|
||||
protected abstract readonly converterFunc: (
|
||||
input: any,
|
||||
existingBin: number[],
|
||||
existingBinLen: number
|
||||
) => packedValue;
|
||||
protected abstract readonly roundFunc: (block: number[], H: StateT) => StateT;
|
||||
protected abstract readonly finalizeFunc: (
|
||||
remainder: number[],
|
||||
remainderBinLen: number,
|
||||
processedBinLen: number,
|
||||
H: StateT,
|
||||
outputLen: number
|
||||
) => number[];
|
||||
protected abstract readonly stateCloneFunc: (state: StateT) => StateT;
|
||||
protected abstract readonly newStateFunc: (variant: VariantT) => StateT;
|
||||
protected abstract readonly getMAC:
|
||||
| ((options: { outputLen: number }) => number[])
|
||||
| null;
|
||||
protected constructor(
|
||||
variant: VariantT,
|
||||
inputFormat: 'TEXT',
|
||||
options?: FixedLengthOptionsEncodingType
|
||||
);
|
||||
protected constructor(
|
||||
variant: VariantT,
|
||||
inputFormat: FormatNoTextType,
|
||||
options?: FixedLengthOptionsNoEncodingType
|
||||
);
|
||||
/**
|
||||
* Hashes as many blocks as possible. Stores the rest for either a future update or getHash call.
|
||||
*
|
||||
* @param srcString The input to be hashed.
|
||||
*/
|
||||
update(srcString: string | ArrayBuffer | Uint8Array): void;
|
||||
/**
|
||||
* Returns the desired SHA hash of the input fed in via `update` calls.
|
||||
*
|
||||
* @param format The desired output formatting
|
||||
* @param options Hashmap of output formatting options. `outputLen` must be specified for variable length hashes.
|
||||
* `outputLen` replaces the now deprecated `shakeLen` key.
|
||||
* @returns The hash in the format specified.
|
||||
*/
|
||||
getHash(
|
||||
format: 'HEX',
|
||||
options?: {
|
||||
outputUpper?: boolean;
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): string;
|
||||
getHash(
|
||||
format: 'B64',
|
||||
options?: {
|
||||
b64Pad?: string;
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): string;
|
||||
getHash(
|
||||
format: 'BYTES',
|
||||
options?: {
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): string;
|
||||
getHash(
|
||||
format: 'UINT8ARRAY',
|
||||
options?: {
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): Uint8Array;
|
||||
getHash(
|
||||
format: 'ARRAYBUFFER',
|
||||
options?: {
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): ArrayBuffer;
|
||||
/**
|
||||
* Sets the HMAC key for an eventual `getHMAC` call. Must be called immediately after jsSHA object instantiation.
|
||||
*
|
||||
* @param key The key used to calculate the HMAC
|
||||
* @param inputFormat The format of key.
|
||||
* @param options Hashmap of extra input options.
|
||||
*/
|
||||
setHMACKey(
|
||||
key: string,
|
||||
inputFormat: 'TEXT',
|
||||
options?: {
|
||||
encoding?: EncodingType;
|
||||
}
|
||||
): void;
|
||||
setHMACKey(key: string, inputFormat: 'B64' | 'HEX' | 'BYTES'): void;
|
||||
setHMACKey(key: ArrayBuffer, inputFormat: 'ARRAYBUFFER'): void;
|
||||
setHMACKey(key: Uint8Array, inputFormat: 'UINT8ARRAY'): void;
|
||||
/**
|
||||
* Internal function that sets the MAC key.
|
||||
*
|
||||
* @param key The packed MAC key to use
|
||||
*/
|
||||
protected _setHMACKey(key: packedValue): void;
|
||||
/**
|
||||
* Returns the the HMAC in the specified format using the key given by a previous `setHMACKey` call.
|
||||
*
|
||||
* @param format The desired output formatting.
|
||||
* @param options Hashmap of extra outputs options.
|
||||
* @returns The HMAC in the format specified.
|
||||
*/
|
||||
getHMAC(
|
||||
format: 'HEX',
|
||||
options?: {
|
||||
outputUpper?: boolean;
|
||||
}
|
||||
): string;
|
||||
getHMAC(
|
||||
format: 'B64',
|
||||
options?: {
|
||||
b64Pad?: string;
|
||||
}
|
||||
): string;
|
||||
getHMAC(format: 'BYTES'): string;
|
||||
getHMAC(format: 'UINT8ARRAY'): Uint8Array;
|
||||
getHMAC(format: 'ARRAYBUFFER'): ArrayBuffer;
|
||||
/**
|
||||
* Internal function that returns the "raw" HMAC
|
||||
*/
|
||||
protected _getHMAC(): number[];
|
||||
}
|
||||
|
||||
declare type VariantType = 'SHA-224' | 'SHA-256';
|
||||
declare class jsSHA extends jsSHABase<number[], VariantType> {
|
||||
intermediateState: number[];
|
||||
variantBlockSize: number;
|
||||
bigEndianMod: -1 | 1;
|
||||
outputBinLen: number;
|
||||
isVariableLen: boolean;
|
||||
HMACSupported: boolean;
|
||||
converterFunc: (
|
||||
input: any,
|
||||
existingBin: number[],
|
||||
existingBinLen: number
|
||||
) => packedValue;
|
||||
roundFunc: (block: number[], H: number[]) => number[];
|
||||
finalizeFunc: (
|
||||
remainder: number[],
|
||||
remainderBinLen: number,
|
||||
processedBinLen: number,
|
||||
H: number[]
|
||||
) => number[];
|
||||
stateCloneFunc: (state: number[]) => number[];
|
||||
newStateFunc: (variant: VariantType) => number[];
|
||||
getMAC: () => number[];
|
||||
constructor(
|
||||
variant: VariantType,
|
||||
inputFormat: 'TEXT',
|
||||
options?: FixedLengthOptionsEncodingType
|
||||
);
|
||||
constructor(
|
||||
variant: VariantType,
|
||||
inputFormat: FormatNoTextType,
|
||||
options?: FixedLengthOptionsNoEncodingType
|
||||
);
|
||||
}
|
21
Extensions/Leaderboards/sha256.js
Normal file
21
Extensions/Leaderboards/sha256.js
Normal file
File diff suppressed because one or more lines are too long
68
Extensions/Leaderboards/tests/leaderboardstools.spec.js
Normal file
68
Extensions/Leaderboards/tests/leaderboardstools.spec.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// @ts-check
|
||||
|
||||
describe('Leaderboards', () => {
|
||||
describe('formatPlayerName', () => {
|
||||
it('it returns name if correct', () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('PlayerName')).to.be(
|
||||
'PlayerName'
|
||||
);
|
||||
});
|
||||
|
||||
it('it returns name with underscores instead of whitespaces', () => {
|
||||
expect(
|
||||
gdjs.evtTools.leaderboards.formatPlayerName('My Player Name')
|
||||
).to.be('My_Player_Name');
|
||||
});
|
||||
|
||||
it("it doesn't change a name with vertical bars and hyphens", () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('Pla-yer|Name')).to.be(
|
||||
'Pla-yer|Name'
|
||||
);
|
||||
});
|
||||
|
||||
it('it truncates name if longer than 30', () => {
|
||||
expect(
|
||||
gdjs.evtTools.leaderboards.formatPlayerName(
|
||||
'aPlayerNameTh4tIsT00LongToBeSaved'
|
||||
)
|
||||
).to.be('aPlayerNameTh4tIsT00LongToBeSa');
|
||||
});
|
||||
|
||||
it('it generates a predefined player name with a random number if input is void/wrong type/empty', () => {
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(null)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(5)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(undefined)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(() => {})).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('')).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
});
|
||||
|
||||
it('it removes accents from latin letters', () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('plâyèrÏonisé')).to.be(
|
||||
'playerIonise'
|
||||
);
|
||||
});
|
||||
|
||||
it('it removes non-accepted characters in a long name', () => {
|
||||
expect(
|
||||
gdjs.evtTools.leaderboards.formatPlayerName(
|
||||
'aPιΥÉᚱnÀⅯeThatᎥsTooⅬonᏀToBeՏaѵÊĐThisPartAppears'
|
||||
)
|
||||
).to.be('aEAeThatsTooonToBeaEThisPartAp');
|
||||
});
|
||||
});
|
||||
});
|
@@ -537,7 +537,7 @@ void DeclarePlatformBehaviorExtension(gd::PlatformExtension& extension) {
|
||||
|
||||
aut.AddScopedCondition("IsUsingControl",
|
||||
_("Control pressed or simulated"),
|
||||
_("A control was applied from a default control or a simulated by an action."),
|
||||
_("A control was applied from a default control or simulated by an action."),
|
||||
_("_PARAM0_ has the _PARAM2_ key pressed or simulated"),
|
||||
_(""),
|
||||
"res/conditions/keyboard24.png",
|
||||
|
@@ -578,12 +578,6 @@ module.exports = {
|
||||
const TEXT_MASK_PADDING = 2;
|
||||
|
||||
class RenderedTextInputObjectInstance extends RenderedInstance {
|
||||
_pixiText;
|
||||
_pixiTextMask;
|
||||
_pixiGraphics;
|
||||
_fontResourceName = '';
|
||||
_finalTextColor = 0x0;
|
||||
|
||||
constructor(
|
||||
project,
|
||||
layout,
|
||||
@@ -601,6 +595,8 @@ module.exports = {
|
||||
pixiResourcesLoader
|
||||
);
|
||||
|
||||
this._fontResourceName = '';
|
||||
this._finalTextColor = 0x0;
|
||||
this._pixiGraphics = new PIXI.Graphics();
|
||||
this._pixiTextMask = new PIXI.Graphics();
|
||||
this._pixiText = new PIXI.Text(' ', {
|
||||
|
@@ -145,6 +145,28 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
|
||||
.SetIncludeFile(
|
||||
"TopDownMovementBehavior/TopDownMovementRuntimeBehavior.h");
|
||||
|
||||
aut.AddScopedCondition("IsUsingControl",
|
||||
_("Control pressed or simulated"),
|
||||
_("A control was applied from a default control or simulated by an action."),
|
||||
_("_PARAM0_ has the _PARAM2_ key pressed or simulated"),
|
||||
_("Controls"),
|
||||
"res/conditions/keyboard24.png",
|
||||
"res/conditions/keyboard.png")
|
||||
.AddParameter("object", _("Object"))
|
||||
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
|
||||
.AddParameter("stringWithSelector",
|
||||
_("Key"),
|
||||
"[\"Left\", \"Right\", \"Up\", \"Down\", \"Stick\"]")
|
||||
.MarkAsAdvanced();
|
||||
|
||||
aut.AddExpression("StickAngle",
|
||||
_("Stick angle"),
|
||||
_("Return the angle of the simulated stick input (in degrees)"),
|
||||
_("Controls"),
|
||||
"CppPlatform/Extensions/topdownmovementicon16.png")
|
||||
.AddParameter("object", _("Object"))
|
||||
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior");
|
||||
|
||||
aut.AddCondition("IsMoving",
|
||||
_("Is moving"),
|
||||
_("Check if the object is moving."),
|
||||
@@ -532,6 +554,30 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
|
||||
.SetIncludeFile(
|
||||
"TopDownMovementBehavior/TopDownMovementRuntimeBehavior.h");
|
||||
|
||||
aut.AddScopedAction("SetVelocityX",
|
||||
_("Speed on the X axis"),
|
||||
_("Change the speed on the X axis of the movement"),
|
||||
_("the speed on the X axis of the movement"),
|
||||
_("Movement"),
|
||||
"CppPlatform/Extensions/topdownmovementicon24.png",
|
||||
"CppPlatform/Extensions/topdownmovementicon16.png")
|
||||
.AddParameter("object", _("Object"))
|
||||
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
|
||||
.UseStandardOperatorParameters("number")
|
||||
.MarkAsAdvanced();
|
||||
|
||||
aut.AddScopedAction("SetVelocityY",
|
||||
_("Speed on the Y axis"),
|
||||
_("Change the speed on the Y axis of the movement"),
|
||||
_("the speed on the Y axis of the movement"),
|
||||
_("Movement"),
|
||||
"CppPlatform/Extensions/topdownmovementicon24.png",
|
||||
"CppPlatform/Extensions/topdownmovementicon16.png")
|
||||
.AddParameter("object", _("Object"))
|
||||
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
|
||||
.UseStandardOperatorParameters("number")
|
||||
.MarkAsAdvanced();
|
||||
|
||||
aut.AddExpressionAndConditionAndAction("number",
|
||||
"MovementAngleOffset",
|
||||
_("Movement angle offset"),
|
||||
|
@@ -106,6 +106,9 @@ class TopDownMovementBehaviorJsExtension : public gd::PlatformExtension {
|
||||
.SetFunctionName("ignoreDefaultControls");
|
||||
autActions["TopDownMovementBehavior::SimulateStick"].SetFunctionName(
|
||||
"simulateStick");
|
||||
autConditions["TopDownMovementBehavior::TopDownMovementBehavior::IsUsingControl"].SetFunctionName(
|
||||
"isUsingControl");
|
||||
autExpressions["StickAngle"].SetFunctionName("getLastStickInputAngle");
|
||||
|
||||
autExpressions["Acceleration"].SetFunctionName("getAcceleration");
|
||||
autExpressions["Deceleration"].SetFunctionName("getDeceleration");
|
||||
@@ -116,6 +119,12 @@ class TopDownMovementBehaviorJsExtension : public gd::PlatformExtension {
|
||||
autExpressions["Angle"].SetFunctionName("getAngle");
|
||||
autExpressions["XVelocity"].SetFunctionName("getXVelocity");
|
||||
autExpressions["YVelocity"].SetFunctionName("getYVelocity");
|
||||
autActions["TopDownMovementBehavior::TopDownMovementBehavior::SetVelocityX"]
|
||||
.SetFunctionName("setXVelocity")
|
||||
.SetGetter("getXVelocity");
|
||||
autActions["TopDownMovementBehavior::TopDownMovementBehavior::SetVelocityY"]
|
||||
.SetFunctionName("setYVelocity")
|
||||
.SetGetter("getYVelocity");
|
||||
autExpressions["MovementAngleOffset"].SetFunctionName(
|
||||
"getMovementAngleOffset");
|
||||
|
||||
|
@@ -99,6 +99,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateDownKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getXVelocity()
|
||||
).to.be.above(0);
|
||||
expect(player.getBehavior(topDownName).getYVelocity()).to.be(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be.above(200 + 20);
|
||||
@@ -120,6 +124,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateUpKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getXVelocity()
|
||||
).to.be.below(0);
|
||||
expect(player.getBehavior(topDownName).getYVelocity()).to.be(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be.below(200 - 20);
|
||||
@@ -141,6 +149,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateLeftKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(player.getBehavior(topDownName).getXVelocity()).to.be(0);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getYVelocity()
|
||||
).to.be.above(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be(200);
|
||||
@@ -162,6 +174,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateLeftKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(player.getBehavior(topDownName).getXVelocity()).to.be(0);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getYVelocity()
|
||||
).to.be.below(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be(200);
|
||||
|
@@ -35,10 +35,11 @@ namespace gdjs {
|
||||
private _rightKey: boolean = false;
|
||||
private _upKey: boolean = false;
|
||||
private _downKey: boolean = false;
|
||||
private _leftKeyPressedDuration: integer = -1;
|
||||
private _rightKeyPressedDuration: integer = -1;
|
||||
private _upKeyPressedDuration: integer = -1;
|
||||
private _downKeyPressedDuration: integer = -1;
|
||||
private _leftKeyPressedDuration: float = 0;
|
||||
private _rightKeyPressedDuration: float = 0;
|
||||
private _upKeyPressedDuration: float = 0;
|
||||
private _downKeyPressedDuration: float = 0;
|
||||
private _wasStickUsed: boolean = false;
|
||||
private _stickAngle: float = 0;
|
||||
private _stickForce: float = 0;
|
||||
|
||||
@@ -192,20 +193,28 @@ namespace gdjs {
|
||||
return this._xVelocity !== 0 || this._yVelocity !== 0;
|
||||
}
|
||||
|
||||
getSpeed() {
|
||||
getSpeed(): float {
|
||||
return Math.sqrt(
|
||||
this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity
|
||||
);
|
||||
}
|
||||
|
||||
getXVelocity() {
|
||||
getXVelocity(): float {
|
||||
return this._xVelocity;
|
||||
}
|
||||
|
||||
getYVelocity() {
|
||||
setXVelocity(velocityX: float): void {
|
||||
this._xVelocity = velocityX;
|
||||
}
|
||||
|
||||
getYVelocity(): float {
|
||||
return this._yVelocity;
|
||||
}
|
||||
|
||||
setYVelocity(velocityY: float): void {
|
||||
this._yVelocity = velocityY;
|
||||
}
|
||||
|
||||
getAngle(): float {
|
||||
return this._angle;
|
||||
}
|
||||
@@ -242,31 +251,31 @@ namespace gdjs {
|
||||
!this._ignoreDefaultControls &&
|
||||
runtimeScene.getGame().getInputManager().isKeyPressed(UPKEY);
|
||||
|
||||
const elapsedTime = this.owner.getElapsedTime(runtimeScene);
|
||||
|
||||
if (!this._leftKey) {
|
||||
this._leftKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._leftKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._rightKey) {
|
||||
this._rightKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._rightKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._downKey) {
|
||||
this._downKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._downKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._upKey) {
|
||||
this._upKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._upKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
|
||||
let direction = -1;
|
||||
if (!this._allowDiagonals) {
|
||||
const elapsedTime = this.owner.getElapsedTime(runtimeScene);
|
||||
|
||||
if (!this._leftKey) {
|
||||
this._leftKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._leftKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._rightKey) {
|
||||
this._rightKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._rightKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._downKey) {
|
||||
this._downKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._downKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._upKey) {
|
||||
this._upKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._upKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
|
||||
if (this._upKey && !this._downKey) {
|
||||
direction = 6;
|
||||
} else if (!this._upKey && this._downKey) {
|
||||
@@ -322,20 +331,34 @@ namespace gdjs {
|
||||
|
||||
const object = this.owner;
|
||||
const timeDelta = this.owner.getElapsedTime(runtimeScene) / 1000;
|
||||
let directionInRad = 0;
|
||||
let directionInDeg = 0;
|
||||
const previousVelocityX = this._xVelocity;
|
||||
const previousVelocityY = this._yVelocity;
|
||||
this._wasStickUsed = false;
|
||||
|
||||
// These 4 values are not actually used.
|
||||
// JavaScript doesn't allow to declare
|
||||
// variables without assigning them a value.
|
||||
let directionInRad = 0;
|
||||
let directionInDeg = 0;
|
||||
let cos = 1;
|
||||
let sin = 0;
|
||||
|
||||
// Update the speed of the object:
|
||||
if (direction !== -1) {
|
||||
directionInRad =
|
||||
((direction + this._movementAngleOffset / 45) * Math.PI) / 4.0;
|
||||
directionInDeg = direction * 45 + this._movementAngleOffset;
|
||||
this._xVelocity +=
|
||||
this._acceleration * timeDelta * Math.cos(directionInRad);
|
||||
this._yVelocity +=
|
||||
this._acceleration * timeDelta * Math.sin(directionInRad);
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity += this._acceleration * timeDelta * cos;
|
||||
this._yVelocity += this._acceleration * timeDelta * sin;
|
||||
} else if (this._stickForce !== 0) {
|
||||
if (!this._allowDiagonals) {
|
||||
this._stickAngle = 90 * Math.floor((this._stickAngle + 45) / 90);
|
||||
@@ -343,19 +366,36 @@ namespace gdjs {
|
||||
directionInDeg = this._stickAngle + this._movementAngleOffset;
|
||||
directionInRad = (directionInDeg * Math.PI) / 180;
|
||||
const norm = this._acceleration * timeDelta * this._stickForce;
|
||||
this._xVelocity += norm * Math.cos(directionInRad);
|
||||
this._yVelocity += norm * Math.sin(directionInRad);
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity += norm * cos;
|
||||
this._yVelocity += norm * sin;
|
||||
|
||||
this._wasStickUsed = true;
|
||||
this._stickForce = 0;
|
||||
} else if (this._yVelocity !== 0 || this._xVelocity !== 0) {
|
||||
directionInRad = Math.atan2(this._yVelocity, this._xVelocity);
|
||||
directionInDeg = (directionInRad * 180.0) / Math.PI;
|
||||
const xVelocityWasPositive = this._xVelocity >= 0;
|
||||
const yVelocityWasPositive = this._yVelocity >= 0;
|
||||
this._xVelocity -=
|
||||
this._deceleration * timeDelta * Math.cos(directionInRad);
|
||||
this._yVelocity -=
|
||||
this._deceleration * timeDelta * Math.sin(directionInRad);
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity -= this._deceleration * timeDelta * cos;
|
||||
this._yVelocity -= this._deceleration * timeDelta * sin;
|
||||
if (this._xVelocity > 0 !== xVelocityWasPositive) {
|
||||
this._xVelocity = 0;
|
||||
}
|
||||
@@ -366,8 +406,8 @@ namespace gdjs {
|
||||
const squaredSpeed =
|
||||
this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity;
|
||||
if (squaredSpeed > this._maxSpeed * this._maxSpeed) {
|
||||
this._xVelocity = this._maxSpeed * Math.cos(directionInRad);
|
||||
this._yVelocity = this._maxSpeed * Math.sin(directionInRad);
|
||||
this._xVelocity = this._maxSpeed * cos;
|
||||
this._yVelocity = this._maxSpeed * sin;
|
||||
}
|
||||
|
||||
// No acceleration for angular speed for now.
|
||||
@@ -451,6 +491,33 @@ namespace gdjs {
|
||||
this._stickAngle = stickAngle % 360;
|
||||
this._stickForce = Math.max(0, Math.min(1, stickForce));
|
||||
}
|
||||
|
||||
/**.
|
||||
* @param input The control to be tested [Left,Right,Up,Down,Stick].
|
||||
* @returns true if the key was used since the last `doStepPreEvents` call.
|
||||
*/
|
||||
isUsingControl(input: string): boolean {
|
||||
if (input === 'Left') {
|
||||
return this._leftKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Right') {
|
||||
return this._rightKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Up') {
|
||||
return this._upKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Down') {
|
||||
return this._downKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Stick') {
|
||||
return this._wasStickUsed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getLastStickInputAngle() {
|
||||
return this._stickAngle;
|
||||
}
|
||||
}
|
||||
export namespace TopDownMovementRuntimeBehavior {
|
||||
export interface BasisTransformation {
|
||||
|
@@ -17,6 +17,8 @@ MouseExtension::MouseExtension() {
|
||||
"gdjs.evtTools.input.getMouseX");
|
||||
GetAllConditions()["MouseY"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getMouseY");
|
||||
GetAllConditions()["IsMouseInsideCanvas"].SetFunctionName(
|
||||
"gdjs.evtTools.input.isMouseInsideCanvas");
|
||||
GetAllConditions()["SourisX"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getMouseX"); // Deprecated
|
||||
GetAllConditions()["SourisY"].SetFunctionName(
|
||||
@@ -53,9 +55,9 @@ MouseExtension::MouseExtension() {
|
||||
"gdjs.evtTools.input.getMouseY"); // Deprecated
|
||||
|
||||
GetAllConditions()["PopStartedTouch"].SetFunctionName(
|
||||
"gdjs.evtTools.input.popStartedTouch");
|
||||
"gdjs.evtTools.input.popStartedTouch"); // Deprecated
|
||||
GetAllConditions()["PopEndedTouch"].SetFunctionName(
|
||||
"gdjs.evtTools.input.popEndedTouch");
|
||||
"gdjs.evtTools.input.popEndedTouch"); // Deprecated
|
||||
|
||||
GetAllConditions()["TouchX"].SetFunctionName("gdjs.evtTools.input.getTouchX");
|
||||
GetAllConditions()["TouchY"].SetFunctionName("gdjs.evtTools.input.getTouchY");
|
||||
@@ -65,9 +67,18 @@ MouseExtension::MouseExtension() {
|
||||
"gdjs.evtTools.input.getTouchY");
|
||||
|
||||
GetAllExpressions()["LastTouchId"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getLastTouchId");
|
||||
"gdjs.evtTools.input.getLastTouchId"); // Deprecated
|
||||
GetAllExpressions()["LastEndedTouchId"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getLastEndedTouchId");
|
||||
"gdjs.evtTools.input.getLastEndedTouchId"); // Deprecated
|
||||
|
||||
GetAllConditions()["HasAnyTouchStarted"].SetFunctionName(
|
||||
"gdjs.evtTools.input.hasAnyTouchStarted");
|
||||
GetAllConditions()["HasTouchEnded"].SetFunctionName(
|
||||
"gdjs.evtTools.input.hasTouchEnded");
|
||||
GetAllExpressions()["StartedTouchCount"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getStartedTouchCount");
|
||||
GetAllExpressions()["StartedTouchId"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getStartedTouchIdentifier");
|
||||
|
||||
GetAllExpressions()["MouseWheelDelta"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getMouseWheelDelta");
|
||||
|
@@ -59,6 +59,8 @@ namespace gdjs {
|
||||
if (
|
||||
// Don't reload Box2d as it would confuse and crash the asm.js library.
|
||||
endsWith(srcFilename, 'box2d.js') ||
|
||||
// Don't reload sha256.js library.
|
||||
endsWith(srcFilename, 'sha256.js') ||
|
||||
// Don't reload shifty.js library.
|
||||
endsWith(srcFilename, 'shifty.js') ||
|
||||
// Don't reload shopify-buy library.
|
||||
|
@@ -6,7 +6,13 @@
|
||||
namespace gdjs {
|
||||
export namespace evtTools {
|
||||
export namespace input {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export let lastTouchId = 0;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export let lastEndedTouchId = 0;
|
||||
|
||||
/**
|
||||
@@ -266,6 +272,12 @@ namespace gdjs {
|
||||
)[1];
|
||||
};
|
||||
|
||||
export const isMouseInsideCanvas = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
return runtimeScene.getGame().getInputManager().isMouseInsideCanvas();
|
||||
};
|
||||
|
||||
export const _cursorIsOnObject = function (obj, runtimeScene) {
|
||||
return obj.cursorOnObject(runtimeScene);
|
||||
};
|
||||
@@ -285,10 +297,10 @@ namespace gdjs {
|
||||
};
|
||||
|
||||
export const getTouchX = function (
|
||||
runtimeScene,
|
||||
identifier,
|
||||
layer,
|
||||
camera
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
identifier: integer,
|
||||
layer: string,
|
||||
camera: integer
|
||||
) {
|
||||
return runtimeScene
|
||||
.getLayer(layer)
|
||||
@@ -298,12 +310,12 @@ namespace gdjs {
|
||||
)[0];
|
||||
};
|
||||
|
||||
export const getTouchY = function (
|
||||
runtimeScene,
|
||||
identifier,
|
||||
layer,
|
||||
camera
|
||||
) {
|
||||
export const getTouchY = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
identifier: integer,
|
||||
layer: string,
|
||||
camera: integer
|
||||
) => {
|
||||
return runtimeScene
|
||||
.getLayer(layer)
|
||||
.convertCoords(
|
||||
@@ -312,15 +324,64 @@ namespace gdjs {
|
||||
)[1];
|
||||
};
|
||||
|
||||
export const hasAnyTouchStarted = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): boolean => {
|
||||
return (
|
||||
runtimeScene.getGame().getInputManager().getStartedTouchIdentifiers()
|
||||
.length > 0
|
||||
);
|
||||
};
|
||||
|
||||
export const getStartedTouchCount = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): integer => {
|
||||
return runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
.getStartedTouchIdentifiers().length;
|
||||
};
|
||||
|
||||
export const getStartedTouchIdentifier = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
index: integer
|
||||
): integer => {
|
||||
return runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
.getStartedTouchIdentifiers()[index];
|
||||
};
|
||||
|
||||
export const hasTouchEnded = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
identifier: integer
|
||||
): boolean => {
|
||||
return runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
.hasTouchEnded(identifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLastTouchId = function () {
|
||||
return gdjs.evtTools.input.lastTouchId || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLastEndedTouchId = function () {
|
||||
return gdjs.evtTools.input.lastEndedTouchId || 0;
|
||||
};
|
||||
|
||||
export const popStartedTouch = function (runtimeScene) {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const popStartedTouch = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
const startedTouchId = runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
@@ -332,7 +393,10 @@ namespace gdjs {
|
||||
return false;
|
||||
};
|
||||
|
||||
export const popEndedTouch = function (runtimeScene) {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const popEndedTouch = function (runtimeScene: gdjs.RuntimeScene) {
|
||||
const endedTouchId = runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
|
@@ -28,6 +28,7 @@ namespace gdjs {
|
||||
_releasedMouseButtons: Array<boolean>;
|
||||
_mouseX: float = 0;
|
||||
_mouseY: float = 0;
|
||||
_isMouseInsideCanvas: boolean = true;
|
||||
_mouseWheelDelta: float = 0;
|
||||
_touches: Hashtable<Touch>;
|
||||
//Identifiers of the touches that started during/before the frame.
|
||||
@@ -37,6 +38,15 @@ namespace gdjs {
|
||||
_endedTouches: Array<integer> = [];
|
||||
_touchSimulateMouse: boolean = true;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
_lastStartedTouchIndex = 0;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
_lastEndedTouchIndex = 0;
|
||||
|
||||
constructor() {
|
||||
this._pressedKeys = new Hashtable();
|
||||
this._releasedKeys = new Hashtable();
|
||||
@@ -193,6 +203,27 @@ namespace gdjs {
|
||||
return this._mouseY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the mouse leave the canvas.
|
||||
*/
|
||||
onMouseLeave(): void {
|
||||
this._isMouseInsideCanvas = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the mouse enter the canvas.
|
||||
*/
|
||||
onMouseEnter(): void {
|
||||
this._isMouseInsideCanvas = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when the mouse is inside the canvas.
|
||||
*/
|
||||
isMouseInsideCanvas(): boolean {
|
||||
return this._isMouseInsideCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called whenever a mouse button is pressed.
|
||||
* @param buttonCode The mouse button code associated to the event.
|
||||
@@ -254,11 +285,11 @@ namespace gdjs {
|
||||
*
|
||||
* @return the touch X position, relative to the game view.
|
||||
*/
|
||||
getTouchX(identifier: integer): float {
|
||||
if (!this._touches.containsKey(identifier)) {
|
||||
getTouchX(publicIdentifier: integer): float {
|
||||
if (!this._touches.containsKey(publicIdentifier)) {
|
||||
return 0;
|
||||
}
|
||||
return this._touches.get(identifier).x;
|
||||
return this._touches.get(publicIdentifier).x;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,11 +297,19 @@ namespace gdjs {
|
||||
*
|
||||
* @return the touch Y position, relative to the game view.
|
||||
*/
|
||||
getTouchY(identifier: integer): float {
|
||||
if (!this._touches.containsKey(identifier)) {
|
||||
getTouchY(publicIdentifier: integer): float {
|
||||
if (!this._touches.containsKey(publicIdentifier)) {
|
||||
return 0;
|
||||
}
|
||||
return this._touches.get(identifier).y;
|
||||
return this._touches.get(publicIdentifier).y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param publicIdentifier the touch identifier
|
||||
* @returns true if the touch has just ended.
|
||||
*/
|
||||
hasTouchEnded(publicIdentifier: integer): boolean {
|
||||
return this._endedTouches.includes(publicIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,17 +325,19 @@ namespace gdjs {
|
||||
return InputManager._allTouchIds;
|
||||
}
|
||||
|
||||
onTouchStart(identifier: integer, x: float, y: float): void {
|
||||
this._startedTouches.push(identifier);
|
||||
this._touches.put(identifier, { x: x, y: y, justEnded: false });
|
||||
onTouchStart(rawIdentifier: integer, x: float, y: float): void {
|
||||
const publicIdentifier = this.getPublicTouchIdentifier(rawIdentifier);
|
||||
this._startedTouches.push(publicIdentifier);
|
||||
this._touches.put(publicIdentifier, { x: x, y: y, justEnded: false });
|
||||
if (this._touchSimulateMouse) {
|
||||
this.onMouseMove(x, y);
|
||||
this.onMouseButtonPressed(InputManager.MOUSE_LEFT_BUTTON);
|
||||
}
|
||||
}
|
||||
|
||||
onTouchMove(identifier: integer, x: float, y: float): void {
|
||||
const touch = this._touches.get(identifier);
|
||||
onTouchMove(rawIdentifier: integer, x: float, y: float): void {
|
||||
const publicIdentifier = this.getPublicTouchIdentifier(rawIdentifier);
|
||||
const touch = this._touches.get(publicIdentifier);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
@@ -307,27 +348,50 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd(identifier: number): void {
|
||||
this._endedTouches.push(identifier);
|
||||
if (this._touches.containsKey(identifier)) {
|
||||
onTouchEnd(rawIdentifier: number): void {
|
||||
const publicIdentifier = this.getPublicTouchIdentifier(rawIdentifier);
|
||||
this._endedTouches.push(publicIdentifier);
|
||||
if (this._touches.containsKey(publicIdentifier)) {
|
||||
//Postpone deletion at the end of the frame
|
||||
this._touches.get(identifier).justEnded = true;
|
||||
this._touches.get(publicIdentifier).justEnded = true;
|
||||
}
|
||||
if (this._touchSimulateMouse) {
|
||||
this.onMouseButtonReleased(InputManager.MOUSE_LEFT_BUTTON);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add 1 to the identifier to avoid identifiers taking
|
||||
* the GDevelop default variable value which is 0.
|
||||
* @param rawIdentifier The identifier given by the browser.
|
||||
* @returns The identifier used in events.
|
||||
*/
|
||||
private getPublicTouchIdentifier(rawIdentifier: integer): integer {
|
||||
return rawIdentifier + 1;
|
||||
}
|
||||
|
||||
getStartedTouchIdentifiers(): integer[] {
|
||||
return this._startedTouches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
popStartedTouch(): integer | undefined {
|
||||
return this._startedTouches.shift();
|
||||
const publicIdentifier = this._startedTouches[
|
||||
this._lastStartedTouchIndex
|
||||
];
|
||||
this._lastStartedTouchIndex++;
|
||||
return publicIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
popEndedTouch(): integer | undefined {
|
||||
return this._endedTouches.shift();
|
||||
const publicIdentifier = this._endedTouches[this._lastEndedTouchIndex];
|
||||
this._lastEndedTouchIndex++;
|
||||
return publicIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,6 +408,13 @@ namespace gdjs {
|
||||
this._touchSimulateMouse = enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the touch events are used to simulate mouse events.
|
||||
*/
|
||||
isSimulatingMouseWithTouch(): boolean {
|
||||
return this._touchSimulateMouse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the input manager that the frame ended, so anything that last
|
||||
* only for one frame (started/ended touches) should be reset.
|
||||
@@ -366,6 +437,8 @@ namespace gdjs {
|
||||
this._releasedKeys.clear();
|
||||
this._releasedMouseButtons.length = 0;
|
||||
this._mouseWheelDelta = 0;
|
||||
this._lastStartedTouchIndex = 0;
|
||||
this._lastEndedTouchIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -81,14 +81,36 @@ namespace gdjs {
|
||||
Number.isInteger(zoomFactor)
|
||||
) {
|
||||
// Camera rounding is important for pixel perfect games.
|
||||
// Otherwise the camera position fractional part is added to
|
||||
// Otherwise, the camera position fractional part is added to
|
||||
// the sprite one and it changes in which direction sprites are rounded.
|
||||
// It makes sprites rounding inconsistent with each other
|
||||
// and they seems to move on pixel left and right.
|
||||
this._pixiContainer.position.x = Math.round(
|
||||
// and they seem to move on pixel left and right.
|
||||
//
|
||||
// PIXI uses a floor function on sprites position on the screen,
|
||||
// so a floor must be applied on the camera position too.
|
||||
// According to the above calculus,
|
||||
// _pixiContainer.position is the opposite of the camera,
|
||||
// this is why the ceil function is used floor(x) = -ceil(-x).
|
||||
//
|
||||
// When the camera directly follows an object,
|
||||
// given this object dimension is even,
|
||||
// the decimal part of onScenePosition and cameraPosition are the same.
|
||||
//
|
||||
// Doing the calculus without rounding:
|
||||
// onScreenPosition = onScenePosition - cameraPosition
|
||||
// onScreenPosition = 980.75 - 200.75
|
||||
// onScreenPosition = 780
|
||||
//
|
||||
// Doing the calculus with rounding:
|
||||
// onScreenPosition = floor(onScenePosition + ceil(-cameraPosition))
|
||||
// onScreenPosition = floor(980.75 + ceil(-200.75))
|
||||
// onScreenPosition = floor(980.75 - 200)
|
||||
// onScreenPosition = floor(780.75)
|
||||
// onScreenPosition = 780
|
||||
this._pixiContainer.position.x = Math.ceil(
|
||||
this._pixiContainer.position.x
|
||||
);
|
||||
this._pixiContainer.position.y = Math.round(
|
||||
this._pixiContainer.position.y = Math.ceil(
|
||||
this._pixiContainer.position.y
|
||||
);
|
||||
}
|
||||
|
@@ -442,7 +442,11 @@ namespace gdjs {
|
||||
/**
|
||||
* Add the standard events handler.
|
||||
*/
|
||||
bindStandardEvents(manager, window, document) {
|
||||
bindStandardEvents(
|
||||
manager: gdjs.InputManager,
|
||||
window: Window,
|
||||
document: Document
|
||||
) {
|
||||
const renderer = this._pixiRenderer;
|
||||
if (!renderer) return;
|
||||
const canvas = renderer.view;
|
||||
@@ -451,7 +455,7 @@ namespace gdjs {
|
||||
//to game coordinates.
|
||||
const that = this;
|
||||
|
||||
function getEventPosition(e) {
|
||||
function getEventPosition(e: MouseEvent | Touch) {
|
||||
const pos = [e.pageX - canvas.offsetLeft, e.pageY - canvas.offsetTop];
|
||||
|
||||
// Handle the fact that the game is stretched to fill the canvas.
|
||||
@@ -462,6 +466,18 @@ namespace gdjs {
|
||||
return pos;
|
||||
}
|
||||
|
||||
function isInsideCanvas(e: MouseEvent | Touch) {
|
||||
const x = e.pageX - canvas.offsetLeft;
|
||||
const y = e.pageY - canvas.offsetTop;
|
||||
|
||||
return (
|
||||
0 <= x &&
|
||||
x < (that._canvasWidth || 1) &&
|
||||
0 <= y &&
|
||||
y < (that._canvasHeight || 1)
|
||||
);
|
||||
}
|
||||
|
||||
//Some browsers lacks definition of some variables used to do calculations
|
||||
//in getEventPosition. They are defined to 0 as they are useless.
|
||||
|
||||
@@ -480,6 +496,7 @@ namespace gdjs {
|
||||
document.documentElement === undefined ||
|
||||
document.documentElement === null
|
||||
) {
|
||||
// @ts-ignore
|
||||
document.documentElement = {};
|
||||
}
|
||||
if (isNaN(document.documentElement.scrollLeft)) {
|
||||
@@ -571,6 +588,12 @@ namespace gdjs {
|
||||
);
|
||||
return false;
|
||||
};
|
||||
canvas.onmouseleave = function (e) {
|
||||
manager.onMouseLeave();
|
||||
};
|
||||
canvas.onmouseenter = function (e) {
|
||||
manager.onMouseEnter();
|
||||
};
|
||||
window.addEventListener(
|
||||
'click',
|
||||
function (e) {
|
||||
@@ -606,6 +629,15 @@ namespace gdjs {
|
||||
for (let i = 0; i < e.changedTouches.length; ++i) {
|
||||
const pos = getEventPosition(e.changedTouches[i]);
|
||||
manager.onTouchMove(e.changedTouches[i].identifier, pos[0], pos[1]);
|
||||
// This works because touch events are sent
|
||||
// when they continue outside of the canvas.
|
||||
if (manager.isSimulatingMouseWithTouch()) {
|
||||
if (isInsideCanvas(e.changedTouches[i])) {
|
||||
manager.onMouseEnter();
|
||||
} else {
|
||||
manager.onMouseLeave();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -55,6 +55,8 @@ namespace gdjs {
|
||||
_scaleMode: 'linear' | 'nearest';
|
||||
_pixelsRounding: boolean;
|
||||
_renderer: RuntimeGameRenderer;
|
||||
_sessionId: string | null;
|
||||
_playerId: string | null;
|
||||
|
||||
//Game loop management (see startGameLoop method)
|
||||
_sceneStack: SceneStack;
|
||||
@@ -120,6 +122,8 @@ namespace gdjs {
|
||||
? new gdjs.DebuggerClient(this)
|
||||
: null;
|
||||
this._isPreview = this._options.isPreview || false;
|
||||
this._sessionId = null;
|
||||
this._playerId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,8 +652,7 @@ namespace gdjs {
|
||||
return;
|
||||
}
|
||||
const baseUrl = 'https://api.gdevelop-app.com/analytics';
|
||||
const playerId = this._makePlayerUuid();
|
||||
let sessionId: string | null = null;
|
||||
this._playerId = this._makePlayerUuid();
|
||||
let lastSessionHitTime = Date.now();
|
||||
fetch(baseUrl + '/session', {
|
||||
method: 'POST',
|
||||
@@ -659,7 +662,7 @@ namespace gdjs {
|
||||
// precisely identify someone.
|
||||
body: JSON.stringify({
|
||||
gameId: this._data.properties.projectUuid,
|
||||
playerId: playerId,
|
||||
playerId: this._playerId,
|
||||
game: {
|
||||
name: this._data.properties.name || '',
|
||||
packageName: this._data.properties.packageName || '',
|
||||
@@ -691,13 +694,13 @@ namespace gdjs {
|
||||
})
|
||||
.then((response) => response.text())
|
||||
.then((returnedSessionId) => {
|
||||
sessionId = returnedSessionId;
|
||||
this._sessionId = returnedSessionId;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
/* Ignore any error */
|
||||
const sendSessionHit = () => {
|
||||
if (!sessionId) {
|
||||
if (!this._sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -711,8 +714,8 @@ namespace gdjs {
|
||||
baseUrl + '/session-hit',
|
||||
JSON.stringify({
|
||||
gameId: this._data.properties.projectUuid,
|
||||
playerId: playerId,
|
||||
sessionId: sessionId,
|
||||
playerId: this._playerId,
|
||||
sessionId: this._sessionId,
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -741,6 +744,7 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
this._sessionMetricsInitialized = true;
|
||||
this._sessionId = this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -762,6 +766,14 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
getPlayerId(): string | null {
|
||||
return this._playerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the game renderer when the window containing the game
|
||||
* has changed size (this can result from a resize of the window,
|
||||
|
@@ -31,6 +31,7 @@ const untransformedPaths = [
|
||||
'GDJS/Runtime/libs/rbush.js',
|
||||
|
||||
// Extensions pre-built files:
|
||||
'Extensions/Leaderboards/sha256.js',
|
||||
'Extensions/Firebase/A_firebasejs',
|
||||
'Extensions/BBText/pixi-multistyle-text/dist',
|
||||
'Extensions/DialogueTree/bondage.js/dist',
|
||||
|
@@ -75,6 +75,7 @@ module.exports = function (config) {
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/LinkedObjects/linkedobjects.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventory.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventorytools.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Leaderboards/leaderboardstools.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',
|
||||
|
@@ -3,7 +3,26 @@
|
||||
*/
|
||||
|
||||
describe('gdjs.InputManager', function() {
|
||||
var inputManager = new gdjs.InputManager();
|
||||
|
||||
const runtimeGame = new gdjs.RuntimeGame({
|
||||
variables: [],
|
||||
resources: {
|
||||
resources: [],
|
||||
},
|
||||
properties: { windowWidth: 800, windowHeight: 600 },
|
||||
});
|
||||
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
|
||||
runtimeScene.loadFromScene({
|
||||
layers: [{ name: '', visibility: true, effects: [] }],
|
||||
variables: [],
|
||||
behaviorsSharedData: [],
|
||||
objects: [],
|
||||
instances: [],
|
||||
});
|
||||
const inputManager = runtimeScene
|
||||
.getGame()
|
||||
.getInputManager();
|
||||
const inputTools = gdjs.evtTools.input;
|
||||
|
||||
it('should handle keyboards events', function() {
|
||||
expect(inputManager.anyKeyPressed()).to.be(false);
|
||||
@@ -90,17 +109,98 @@ describe('gdjs.InputManager', function() {
|
||||
expect(
|
||||
inputManager.isMouseButtonReleased(gdjs.InputManager.MOUSE_LEFT_BUTTON)
|
||||
).to.be(false);
|
||||
|
||||
expect(inputManager.isMouseInsideCanvas()).to.be(true);
|
||||
inputManager.onMouseLeave();
|
||||
expect(inputManager.isMouseInsideCanvas()).to.be(false);
|
||||
inputManager.onMouseEnter();
|
||||
expect(inputManager.isMouseInsideCanvas()).to.be(true);
|
||||
});
|
||||
|
||||
it('should handle touch events', function() {
|
||||
inputManager.onTouchStart(46, 510, 610);
|
||||
inputManager.onTouchStart(10, 510, 610);
|
||||
expect(inputManager.getStartedTouchIdentifiers()).to.have.length(2);
|
||||
expect(inputManager.getTouchX(46)).to.be(510);
|
||||
expect(inputManager.getTouchY(46)).to.be(610);
|
||||
inputManager.onTouchStart(10, 470, 320);
|
||||
|
||||
expect(inputManager.popStartedTouch()).to.be(46);
|
||||
expect(inputManager.popStartedTouch()).to.be(10);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getStartedTouchCount(runtimeScene)).to.be(2);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 0)).to.be(47);
|
||||
expect(inputTools.getTouchX(runtimeScene, 47)).to.be(510);
|
||||
expect(inputTools.getTouchY(runtimeScene, 47)).to.be(610);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 1)).to.be(11);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
// Events can ask touches again
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getStartedTouchCount(runtimeScene)).to.be(2);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
inputManager.onTouchEnd(10);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 11)).to.be(true);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 47)).to.be(false);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(false);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(1);
|
||||
});
|
||||
|
||||
it('should handle legacy and new touch events without any conflict', function() {
|
||||
inputManager.onTouchStart(46, 510, 610);
|
||||
inputManager.onTouchStart(10, 470, 320);
|
||||
|
||||
// legacy ones
|
||||
expect(inputTools.popStartedTouch(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getLastTouchId(runtimeScene)).to.be(47);
|
||||
expect(inputTools.getTouchX(runtimeScene, 47)).to.be(510);
|
||||
expect(inputTools.getTouchY(runtimeScene, 47)).to.be(610);
|
||||
expect(inputTools.popStartedTouch(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getLastTouchId(runtimeScene)).to.be(11);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene)).to.be(false);
|
||||
|
||||
// new ones
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getStartedTouchCount(runtimeScene)).to.be(2);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 0)).to.be(47);
|
||||
expect(inputTools.getTouchX(runtimeScene, 47)).to.be(510);
|
||||
expect(inputTools.getTouchY(runtimeScene, 47)).to.be(610);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 1)).to.be(11);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
inputManager.onTouchEnd(10);
|
||||
|
||||
// legacy ones
|
||||
expect(inputTools.popEndedTouch(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getLastEndedTouchId()).to.be(11);
|
||||
expect(inputTools.popEndedTouch(runtimeScene)).to.be(false);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(false);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
// new ones
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 11)).to.be(true);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 47)).to.be(false);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(false);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(1);
|
||||
});
|
||||
|
||||
it('should handle deprecated touch events', function() {
|
||||
inputManager.onTouchStart(46, 510, 610);
|
||||
inputManager.onTouchStart(10, 470, 320);
|
||||
expect(inputManager.getStartedTouchIdentifiers()).to.have.length(2);
|
||||
expect(inputManager.getTouchX(47)).to.be(510);
|
||||
expect(inputManager.getTouchY(47)).to.be(610);
|
||||
|
||||
expect(inputManager.popStartedTouch()).to.be(47);
|
||||
expect(inputManager.popStartedTouch()).to.be(11);
|
||||
expect(inputManager.popEndedTouch()).to.be(undefined);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
@@ -108,13 +208,14 @@ describe('gdjs.InputManager', function() {
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(2);
|
||||
expect(inputManager.getStartedTouchIdentifiers()).to.have.length(0);
|
||||
expect(inputManager.popStartedTouch()).to.be(undefined);
|
||||
expect(inputManager.popEndedTouch()).to.be(10);
|
||||
expect(inputManager.getTouchX(10)).to.be(510);
|
||||
expect(inputManager.getTouchY(10)).to.be(610);
|
||||
expect(inputManager.popEndedTouch()).to.be(11);
|
||||
expect(inputManager.getTouchX(11)).to.be(470);
|
||||
expect(inputManager.getTouchY(11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(1);
|
||||
});
|
||||
|
||||
it('should simulate (or not) mouse events', function() {
|
||||
inputManager.touchSimulateMouse();
|
||||
expect(inputManager.isMouseButtonPressed(0)).to.be(false);
|
||||
|
22
GDevelop.js/package-lock.json
generated
22
GDevelop.js/package-lock.json
generated
@@ -1214,9 +1214,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"parse5": {
|
||||
@@ -8019,8 +8019,8 @@
|
||||
"dev": true
|
||||
},
|
||||
"webidl-tools": {
|
||||
"version": "git://github.com/4ian/webidl-tools.git#87247d37944d9cfdecb4f73da93289929b4077df",
|
||||
"from": "git://github.com/4ian/webidl-tools.git#87247d37944d9cfdecb4f73da93289929b4077df",
|
||||
"version": "github:4ian/webidl-tools#348f9c03afc9d8f278efccdd74543e265a41fd11",
|
||||
"from": "github:4ian/webidl-tools#348f9c03afc9d8f278efccdd74543e265a41fd11",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cheerio": "^0.20.0",
|
||||
@@ -8032,13 +8032,13 @@
|
||||
"stream-concat": "^0.1.0",
|
||||
"vinyl": "^1.1.1",
|
||||
"vinyl-fs": "^2.4.2",
|
||||
"webidl2": "git://github.com/markandrus/webidl2.js.git#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"webidl2": "github:markandrus/webidl2.js#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"winston": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"webidl2": {
|
||||
"version": "git://github.com/markandrus/webidl2.js.git#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"from": "git://github.com/markandrus/webidl2.js.git#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"version": "github:markandrus/webidl2.js#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"from": "github:markandrus/webidl2.js#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"dev": true
|
||||
},
|
||||
"whatwg-encoding": {
|
||||
@@ -8129,9 +8129,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"winston": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-2.4.4.tgz",
|
||||
"integrity": "sha512-NBo2Pepn4hK4V01UfcWcDlmiVTs7VTB1h7bgnB0rgP146bYhMxX0ypCz3lBOfNxCO4Zuek7yeT+y/zM1OfMw4Q==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-2.4.5.tgz",
|
||||
"integrity": "sha512-TWoamHt5yYvsMarGlGEQE59SbJHqGsZV8/lwC+iCcGeAe0vUaOh+Lv6SYM17ouzC/a/LB1/hz/7sxFBtlu1l4A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"async": "~1.0.0",
|
||||
|
@@ -33,7 +33,7 @@
|
||||
"grunt-string-replace": "^1.3.1",
|
||||
"jest": "^23.5.0",
|
||||
"shelljs": "^0.8.4",
|
||||
"webidl-tools": "git://github.com/4ian/webidl-tools.git#87247d37944d9cfdecb4f73da93289929b4077df"
|
||||
"webidl-tools": "github:4ian/webidl-tools#348f9c03afc9d8f278efccdd74543e265a41fd11"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
|
36
newIDE/app/package-lock.json
generated
36
newIDE/app/package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "file:src/Utils/BlueprintJsPlaceholder",
|
||||
"@blueprintjs/icons": "file:src/Utils/BlueprintJsPlaceholder",
|
||||
"@lingui/react": "git://github.com/4ian/lingui-react.git#master",
|
||||
"@lingui/react": "github:4ian/lingui-react#master",
|
||||
"@material-ui/core": "4.11.0",
|
||||
"@material-ui/icons": "4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.56",
|
||||
@@ -32,7 +32,7 @@
|
||||
"keen-tracking": "1.1.3",
|
||||
"lodash": "4.17.4",
|
||||
"node-require-function": "^1.2.0",
|
||||
"pixi-simple-gesture": "git://github.com/4ian/pixi-simple-gesture#v0.3.3",
|
||||
"pixi-simple-gesture": "github:4ian/pixi-simple-gesture#v0.3.3",
|
||||
"pixi.js-legacy": "^6.1.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"randomcolor": "^0.5.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"react-markdown": "^4.0.6",
|
||||
"react-measure": "2.3.0",
|
||||
"react-monaco-editor": "^0.18.0",
|
||||
"react-mosaic-component": "git://github.com/4ian/react-mosaic#v3.1.0",
|
||||
"react-mosaic-component": "github:4ian/react-mosaic#v3.1.0",
|
||||
"react-share": "^4.4.0",
|
||||
"react-sortable-hoc": "1.5.0",
|
||||
"react-sortable-tree": "2.6.2",
|
||||
@@ -41756,14 +41756,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-mosaic-component/node_modules/classnames": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
|
||||
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
|
||||
},
|
||||
"node_modules/react-mosaic-component/node_modules/lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/react-popper": {
|
||||
"version": "2.2.5",
|
||||
@@ -54912,7 +54912,7 @@
|
||||
"@lingui/react": {
|
||||
"version": "git+ssh://git@github.com/4ian/lingui-react.git#dc6b1e013470d952cf85f96cc4affdd28e29634a",
|
||||
"integrity": "sha512-eoYJ8TI+8IolPh4fue9aIwX2OVp0YrPnV86QBZLfGhxknodVeNmx+4Ic4ym7rI5/davbk9AUZHcssiH+YZWVxw==",
|
||||
"from": "@lingui/react@git://github.com/4ian/lingui-react.git#master",
|
||||
"from": "@lingui/react@github:4ian/lingui-react#master",
|
||||
"requires": {
|
||||
"@lingui/core": "2.7.3",
|
||||
"babel-runtime": "^6.26.0",
|
||||
@@ -80358,7 +80358,7 @@
|
||||
"pixi-simple-gesture": {
|
||||
"version": "git+ssh://git@github.com/4ian/pixi-simple-gesture.git#c84e0cc3c62edeca019e708d9897ef6b97a0d18a",
|
||||
"integrity": "sha512-DG1BxP8SK2iPMYWMOPGz5gKDXFmA8JPUpcyyNyIH55fpQraenuLYlosYFFMTRXEy0RZViTUu11H3VrYlfG2CgA==",
|
||||
"from": "pixi-simple-gesture@git://github.com/4ian/pixi-simple-gesture#v0.3.3"
|
||||
"from": "pixi-simple-gesture@github:4ian/pixi-simple-gesture#v0.3.3"
|
||||
},
|
||||
"pixi.js": {
|
||||
"version": "6.1.2",
|
||||
@@ -83077,7 +83077,7 @@
|
||||
"react-mosaic-component": {
|
||||
"version": "git+ssh://git@github.com/4ian/react-mosaic.git#d5ef155119d786c08c7c72e34997dcef2f01f98b",
|
||||
"integrity": "sha512-Izfw/EkG1g39nrZbOqzY52rqFkVFA1SUSv1TLwk7soS1Wy7iHm6zrUgzJdfwKRC2GaDn9WAfSe5ZQ2vIJ/mu5A==",
|
||||
"from": "react-mosaic-component@git://github.com/4ian/react-mosaic#v3.1.0",
|
||||
"from": "react-mosaic-component@github:4ian/react-mosaic#v3.1.0",
|
||||
"requires": {
|
||||
"classnames": "^2.2.6",
|
||||
"immutability-helper": "^3.0.1",
|
||||
@@ -83091,14 +83091,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
|
||||
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
|
||||
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -33,7 +33,7 @@
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "file:src/Utils/BlueprintJsPlaceholder",
|
||||
"@blueprintjs/icons": "file:src/Utils/BlueprintJsPlaceholder",
|
||||
"@lingui/react": "git://github.com/4ian/lingui-react.git#master",
|
||||
"@lingui/react": "github:4ian/lingui-react#master",
|
||||
"@material-ui/core": "4.11.0",
|
||||
"@material-ui/icons": "4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.56",
|
||||
@@ -53,7 +53,7 @@
|
||||
"keen-tracking": "1.1.3",
|
||||
"lodash": "4.17.4",
|
||||
"node-require-function": "^1.2.0",
|
||||
"pixi-simple-gesture": "git://github.com/4ian/pixi-simple-gesture#v0.3.3",
|
||||
"pixi-simple-gesture": "github:4ian/pixi-simple-gesture#v0.3.3",
|
||||
"pixi.js-legacy": "^6.1.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"randomcolor": "^0.5.3",
|
||||
@@ -70,7 +70,7 @@
|
||||
"react-markdown": "^4.0.6",
|
||||
"react-measure": "2.3.0",
|
||||
"react-monaco-editor": "^0.18.0",
|
||||
"react-mosaic-component": "git://github.com/4ian/react-mosaic#v3.1.0",
|
||||
"react-mosaic-component": "github:4ian/react-mosaic#v3.1.0",
|
||||
"react-share": "^4.4.0",
|
||||
"react-sortable-hoc": "1.5.0",
|
||||
"react-sortable-tree": "2.6.2",
|
||||
|
7
newIDE/app/public/JsPlatform/Extensions/leaderboard.svg
Normal file
7
newIDE/app/public/JsPlatform/Extensions/leaderboard.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="62" height="62" viewBox="0 0 62 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M47.1917 9.52621C61.6331 3.903 62.0217 29.4536 45.9739 23.6472L47.1917 9.52621Z" stroke="#2B3990" stroke-width="5"/>
|
||||
<path d="M14.8011 9.96676C0.361326 4.34356 -0.0272331 29.8942 16.0187 24.0878L14.8011 9.96676Z" stroke="#2B3990" stroke-width="5"/>
|
||||
<path d="M18.2426 30.1975C12.1426 23.6797 13.7746 11.1717 14.4872 4.10922H32.3022H47.9793C47.9793 4.10922 49.7339 23.6185 44.2953 30.1975C38.8567 36.7765 23.1267 35.4161 18.2426 30.1975Z" fill="#27AAE1" stroke="#2B3990" stroke-width="5"/>
|
||||
<path d="M31.175 35.033C27.3891 35.033 31.175 35.033 25.1917 57.6229H37.2175C31.175 35.033 34.9609 35.033 31.175 35.033Z" fill="#27AAE1" stroke="#2B3990" stroke-width="5"/>
|
||||
<rect x="18.6728" y="55.1399" width="25.0635" height="6.3261" rx="3" fill="#2B3990"/>
|
||||
</svg>
|
After Width: | Height: | Size: 859 B |
@@ -23,6 +23,7 @@ import { IconContainer } from '../../UI/IconContainer';
|
||||
import { UserPublicProfileChip } from '../../UI/User/UserPublicProfileChip';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import Window from '../../Utils/Window';
|
||||
import { useExtensionUpdate } from './UseExtensionUpdates';
|
||||
|
||||
const getTransformedDescription = (extensionHeader: ExtensionHeader) => {
|
||||
if (
|
||||
@@ -45,7 +46,7 @@ type Props = {|
|
||||
onClose: () => void,
|
||||
onInstall: () => Promise<void>,
|
||||
onEdit?: () => void,
|
||||
alreadyInstalled: boolean,
|
||||
project: gdProject,
|
||||
|};
|
||||
|
||||
const ExtensionInstallDialog = ({
|
||||
@@ -54,8 +55,13 @@ const ExtensionInstallDialog = ({
|
||||
onClose,
|
||||
onInstall,
|
||||
onEdit,
|
||||
alreadyInstalled,
|
||||
project,
|
||||
}: Props) => {
|
||||
const alreadyInstalled = project.hasEventsFunctionsExtensionNamed(
|
||||
extensionShortHeader.name
|
||||
);
|
||||
const extensionUpdate = useExtensionUpdate(project, extensionShortHeader);
|
||||
|
||||
const [error, setError] = React.useState<?Error>(null);
|
||||
const [
|
||||
extensionHeader,
|
||||
@@ -118,7 +124,11 @@ const ExtensionInstallDialog = ({
|
||||
!isCompatible ? (
|
||||
<Trans>Not compatible</Trans>
|
||||
) : alreadyInstalled ? (
|
||||
<Trans>Re-install/update</Trans>
|
||||
extensionUpdate ? (
|
||||
<Trans>Update</Trans>
|
||||
) : (
|
||||
<Trans>Re-install</Trans>
|
||||
)
|
||||
) : (
|
||||
<Trans>Install in project</Trans>
|
||||
)
|
||||
|
@@ -0,0 +1,58 @@
|
||||
//@flow
|
||||
import { diff } from 'semver/functions/diff';
|
||||
import { useMemo } from 'react';
|
||||
import type { ExtensionShortHeader } from '../../Utils/GDevelopServices/Extension';
|
||||
|
||||
type UpdateType = 'patch' | 'minor' | 'major';
|
||||
type UpdateMetadata = {|
|
||||
type: UpdateType,
|
||||
currentVersion: string,
|
||||
newestVersion: string,
|
||||
|};
|
||||
|
||||
const getUpdateMetadataFromVersions = (
|
||||
currentVersion: string,
|
||||
newestVersion: string
|
||||
): UpdateMetadata | null => {
|
||||
try {
|
||||
const versionDiff: UpdateType = diff(currentVersion, newestVersion);
|
||||
if (['patch', 'minor', 'major'].includes(versionDiff)) {
|
||||
return {
|
||||
type: versionDiff,
|
||||
currentVersion,
|
||||
newestVersion,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// An error will be thrown here only if the version is not in semver.
|
||||
// Simply compare the strings for such extensions.
|
||||
// Note that this is an edge case, the extension repository enforces semver, so this
|
||||
// is only for local extensions that do not respect the best practices.
|
||||
if (currentVersion !== newestVersion) {
|
||||
return {
|
||||
// Use minor as it is the most neutral option
|
||||
type: 'minor',
|
||||
currentVersion,
|
||||
newestVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useExtensionUpdate = (
|
||||
project: gdProject,
|
||||
extension: ExtensionShortHeader
|
||||
): UpdateMetadata | null => {
|
||||
return useMemo<UpdateMetadata | null>(
|
||||
() =>
|
||||
project.hasEventsFunctionsExtensionNamed(extension.name)
|
||||
? getUpdateMetadataFromVersions(
|
||||
project.getEventsFunctionsExtension(extension.name).getVersion(),
|
||||
extension.version
|
||||
)
|
||||
: null,
|
||||
[project, extension]
|
||||
);
|
||||
};
|
@@ -121,11 +121,9 @@ export const ExtensionStore = ({
|
||||
</ResponsiveWindowMeasurer>
|
||||
{!!selectedExtensionShortHeader && (
|
||||
<ExtensionInstallDialog
|
||||
project={project}
|
||||
isInstalling={isInstalling}
|
||||
extensionShortHeader={selectedExtensionShortHeader}
|
||||
alreadyInstalled={project.hasEventsFunctionsExtensionNamed(
|
||||
selectedExtensionShortHeader.name
|
||||
)}
|
||||
onInstall={async () => {
|
||||
const wasInstalled = await onInstall(selectedExtensionShortHeader);
|
||||
if (wasInstalled) setSelectedExtensionShortHeader(null);
|
||||
|
@@ -71,6 +71,7 @@ export const create = (authentication: Authentication) => {
|
||||
renderExportDialog={props => (
|
||||
<ExportDialog
|
||||
project={props.project}
|
||||
onSaveProject={props.onSaveProject}
|
||||
onChangeSubscription={props.onChangeSubscription}
|
||||
onClose={props.onClose}
|
||||
automatedExporters={browserAutomatedExporters}
|
||||
|
@@ -124,10 +124,14 @@ export default class EventsBasedBehaviorPropertiesEditor extends React.Component
|
||||
|
||||
_setChoiceExtraInfo = (property: gdNamedPropertyDescriptor) => {
|
||||
return (newExtraInfo: Array<string>) => {
|
||||
const defaultValueIndex = getExtraInfoArray(property).indexOf(
|
||||
property.getValue()
|
||||
);
|
||||
const vectorString = new gd.VectorString();
|
||||
newExtraInfo.forEach(item => vectorString.push_back(item));
|
||||
property.setExtraInfo(vectorString);
|
||||
vectorString.delete();
|
||||
property.setValue(newExtraInfo[defaultValueIndex] || '');
|
||||
this.forceUpdate();
|
||||
};
|
||||
};
|
||||
@@ -334,6 +338,28 @@ export default class EventsBasedBehaviorPropertiesEditor extends React.Component
|
||||
disabled={false}
|
||||
/>
|
||||
)}
|
||||
{property.getType() === 'Choice' && (
|
||||
<SelectField
|
||||
floatingLabelText={<Trans>Default value</Trans>}
|
||||
value={property.getValue()}
|
||||
onChange={(e, i, value) => {
|
||||
property.setValue(value);
|
||||
this.forceUpdate();
|
||||
this.props.onPropertiesUpdated();
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
{getExtraInfoArray(property).map(
|
||||
(choice, index) => (
|
||||
<SelectOption
|
||||
key={index}
|
||||
value={choice}
|
||||
primaryText={choice}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</SelectField>
|
||||
)}
|
||||
</ResponsiveLineStackLayout>
|
||||
{property.getType() === 'Choice' && (
|
||||
<StringArrayEditor
|
||||
|
@@ -343,7 +343,7 @@ export default function NewInstructionEditorDialog({
|
||||
cannotBeDismissed={true}
|
||||
maxWidth={false}
|
||||
noMargin
|
||||
flexRowBody
|
||||
flexBody
|
||||
fullHeight={
|
||||
true /* Always use full height to avoid a very small dialog when there are not a lot of objects. */
|
||||
}
|
||||
|
231
newIDE/app/src/EventsSheet/ParameterFields/LeaderboardIdField.js
Normal file
231
newIDE/app/src/EventsSheet/ParameterFields/LeaderboardIdField.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import OpenInNew from '@material-ui/icons/OpenInNew';
|
||||
import { type ParameterInlineRendererProps } from './ParameterInlineRenderer.flow';
|
||||
import {
|
||||
type ParameterFieldProps,
|
||||
type ParameterFieldInterface,
|
||||
} from './ParameterFieldCommons';
|
||||
import SelectField from '../../UI/SelectField';
|
||||
import SelectOption from '../../UI/SelectOption';
|
||||
import { TextFieldWithButtonLayout } from '../../UI/Layout';
|
||||
import RaisedButtonWithSplitMenu from '../../UI/RaisedButtonWithSplitMenu';
|
||||
import { type Leaderboard } from '../../Utils/GDevelopServices/Play';
|
||||
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
|
||||
import LeaderboardDialog from '../../Leaderboard/LeaderboardDialog';
|
||||
import GenericExpressionField from './GenericExpressionField';
|
||||
import { breakUuid } from '../../Utils/GDevelopServices/Play';
|
||||
import { useOnlineStatus } from '../../Utils/OnlineStatus';
|
||||
|
||||
const getInlineParameterDisplayValue = (
|
||||
leaderboards: ?Array<Leaderboard>,
|
||||
value: string
|
||||
): string => {
|
||||
if (!leaderboards) return value;
|
||||
const leaderboard = leaderboards.find(
|
||||
leaderboard => `"${leaderboard.id}"` === value
|
||||
);
|
||||
return leaderboard ? leaderboard.name : value;
|
||||
};
|
||||
|
||||
const useFetchLeaderboards = () => {
|
||||
const { leaderboards, listLeaderboards } = React.useContext(
|
||||
LeaderboardContext
|
||||
);
|
||||
const fetchLeaderboards = React.useCallback(
|
||||
async () => {
|
||||
await listLeaderboards();
|
||||
},
|
||||
[listLeaderboards]
|
||||
);
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!leaderboards) {
|
||||
fetchLeaderboards();
|
||||
}
|
||||
},
|
||||
[fetchLeaderboards, leaderboards]
|
||||
);
|
||||
|
||||
return leaderboards;
|
||||
};
|
||||
|
||||
export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
|
||||
function LeaderboardIdField(props, ref) {
|
||||
const isOnline = useOnlineStatus();
|
||||
const leaderboards = useFetchLeaderboards();
|
||||
const [isAdminOpen, setIsAdminOpen] = React.useState(false);
|
||||
const inputFieldRef = React.useRef<?(GenericExpressionField | SelectField)>(
|
||||
null
|
||||
);
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.focus();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const isCurrentValueInLeaderboardList =
|
||||
leaderboards &&
|
||||
!!leaderboards.find(leaderboard => `"${leaderboard.id}"` === props.value);
|
||||
|
||||
const [isExpressionField, setIsExpressionField] = React.useState(
|
||||
!leaderboards || (!!props.value && !isCurrentValueInLeaderboardList)
|
||||
);
|
||||
|
||||
const onChangeSelectValue = (event, value) => {
|
||||
props.onChange(event.target.value);
|
||||
};
|
||||
|
||||
const onChangeTextValue = (value: string) => {
|
||||
props.onChange(value);
|
||||
};
|
||||
|
||||
const fieldLabel = props.parameterMetadata
|
||||
? props.parameterMetadata.getDescription()
|
||||
: undefined;
|
||||
|
||||
const gameHasLeaderboards = leaderboards && leaderboards.length > 0;
|
||||
|
||||
const selectOptions = React.useMemo(
|
||||
() =>
|
||||
leaderboards && gameHasLeaderboards
|
||||
? leaderboards.map(leaderboard => (
|
||||
<SelectOption
|
||||
key={leaderboard.id}
|
||||
value={`"${leaderboard.id}"`}
|
||||
primaryText={`${leaderboard.name} ${
|
||||
leaderboard.id ? `(${breakUuid(leaderboard.id)})` : ''
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
: [
|
||||
<SelectOption
|
||||
disabled
|
||||
key="empty"
|
||||
value="empty"
|
||||
primaryText={''}
|
||||
/>,
|
||||
],
|
||||
[leaderboards, gameHasLeaderboards]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<>
|
||||
<TextFieldWithButtonLayout
|
||||
renderTextField={() =>
|
||||
!isExpressionField ? (
|
||||
<SelectField
|
||||
ref={inputFieldRef}
|
||||
value={props.value}
|
||||
onChange={onChangeSelectValue}
|
||||
margin={props.isInline ? 'none' : 'dense'}
|
||||
fullWidth
|
||||
floatingLabelText={fieldLabel}
|
||||
hintText={
|
||||
gameHasLeaderboards
|
||||
? props.parameterMetadata &&
|
||||
props.parameterMetadata.isOptional()
|
||||
? t`Choose a leaderboard (optional)`
|
||||
: t`Choose a leaderboard`
|
||||
: t`No leaderboards`
|
||||
}
|
||||
helperMarkdownText={
|
||||
!gameHasLeaderboards
|
||||
? i18n._(
|
||||
t`There are currently no leaderboards created for this game. Open the leaderboards manager to create one.`
|
||||
)
|
||||
: (props.parameterMetadata &&
|
||||
props.parameterMetadata.getLongDescription()) ||
|
||||
null
|
||||
}
|
||||
>
|
||||
{selectOptions}
|
||||
</SelectField>
|
||||
) : (
|
||||
<GenericExpressionField
|
||||
ref={inputFieldRef}
|
||||
expressionType="string"
|
||||
{...props}
|
||||
onChange={onChangeTextValue}
|
||||
onExtractAdditionalErrors={(
|
||||
currentExpression: string,
|
||||
currentExpressionNode: gdExpressionNode
|
||||
) => {
|
||||
if (!leaderboards) {
|
||||
if (!isOnline)
|
||||
return 'Unable to fetch leaderboards as you are offline.';
|
||||
return 'Your game may not be registered, create one in the leaderboard manager.';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
renderButton={style => (
|
||||
<RaisedButtonWithSplitMenu
|
||||
icon={<OpenInNew />}
|
||||
style={style}
|
||||
primary
|
||||
onClick={() => setIsAdminOpen(true)}
|
||||
buildMenuTemplate={i18n => [
|
||||
{
|
||||
label: isExpressionField
|
||||
? i18n._(t`Select the leaderboard from a list`)
|
||||
: i18n._(
|
||||
t`Enter the leaderboard id as a text or an expression`
|
||||
),
|
||||
disabled: !leaderboards,
|
||||
click: () => setIsExpressionField(!isExpressionField),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isAdminOpen && !!props.project && (
|
||||
<LeaderboardDialog
|
||||
onClose={() => setIsAdminOpen(false)}
|
||||
open={isAdminOpen}
|
||||
project={props.project}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const InlineLeaderboardIdField = ({
|
||||
value,
|
||||
parameterMetadata,
|
||||
InvalidParameterValue,
|
||||
}: ParameterInlineRendererProps) => {
|
||||
const leaderboards = useFetchLeaderboards();
|
||||
|
||||
if (!value) {
|
||||
if (parameterMetadata.isOptional()) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>No leaderboard chosen</Trans>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InvalidParameterValue isEmpty>
|
||||
<Trans>Choose a leaderboard</Trans>
|
||||
</InvalidParameterValue>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span>{getInlineParameterDisplayValue(leaderboards, value)}</span>;
|
||||
};
|
||||
|
||||
export const renderInlineLeaderboardIdField = (
|
||||
props: ParameterInlineRendererProps
|
||||
) => <InlineLeaderboardIdField {...props} />;
|
@@ -56,6 +56,9 @@ import ObjectAnimationNameField from './ParameterFields/ObjectAnimationNameField
|
||||
import FunctionParameterNameField from './ParameterFields/FunctionParameterNameField';
|
||||
import ExternalLayoutNameField from './ParameterFields/ExternalLayoutNameField';
|
||||
import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
|
||||
import LeaderboardIdField, {
|
||||
renderInlineLeaderboardIdField,
|
||||
} from './ParameterFields/LeaderboardIdField';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const components = {
|
||||
@@ -95,6 +98,7 @@ const components = {
|
||||
objectAnimationName: ObjectAnimationNameField,
|
||||
functionParameterName: FunctionParameterNameField,
|
||||
externalLayoutName: ExternalLayoutNameField,
|
||||
leaderboardId: LeaderboardIdField,
|
||||
};
|
||||
const inlineRenderers: { [string]: ParameterInlineRenderer } = {
|
||||
default: renderInlineDefaultField,
|
||||
@@ -109,6 +113,7 @@ const inlineRenderers: { [string]: ParameterInlineRenderer } = {
|
||||
trueorfalse: renderInlineTrueFalse,
|
||||
operator: renderInlineOperator,
|
||||
relationalOperator: renderInlineRelationalOperator,
|
||||
leaderboardId: renderInlineLeaderboardIdField,
|
||||
};
|
||||
const userFriendlyTypeName: { [string]: MessageDescriptor } = {
|
||||
mouse: t`Mouse button`,
|
||||
|
@@ -92,6 +92,9 @@ import {
|
||||
addCreateBadgePreHookIfNotClaimed,
|
||||
TRIVIAL_FIRST_EVENT,
|
||||
} from '../Utils/GDevelopServices/Badge';
|
||||
import LeaderboardContext, {
|
||||
type LeaderboardState,
|
||||
} from '../Leaderboard/LeaderboardContext';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const zoomLevel = { min: 1, max: 50 };
|
||||
@@ -124,6 +127,7 @@ type ComponentProps = {|
|
||||
...Props,
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
preferences: Preferences,
|
||||
leaderboardsManager: ?LeaderboardState,
|
||||
|};
|
||||
|
||||
type State = {|
|
||||
@@ -1563,11 +1567,13 @@ const EventsSheet = (props, ref) => {
|
||||
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
const leaderboardsManager = React.useContext(LeaderboardContext);
|
||||
return (
|
||||
<EventsSheetComponentWithoutHandle
|
||||
ref={component}
|
||||
authenticatedUser={authenticatedUser}
|
||||
preferences={preferences}
|
||||
leaderboardsManager={leaderboardsManager}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@@ -24,9 +24,8 @@ import {
|
||||
} from '../ExportPipeline.flow';
|
||||
import {
|
||||
ExplanationHeader,
|
||||
WebProjectLink,
|
||||
OnlineGameLink,
|
||||
} from '../GenericExporters/OnlineWebExport';
|
||||
import { type BuildStep } from '../Builds/BuildStepsProgress';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
type ExportState = null;
|
||||
@@ -73,17 +72,19 @@ export const browserOnlineWebExportPipeline: ExportPipeline<
|
||||
|
||||
renderLaunchButtonLabel: () => <Trans>Generate link</Trans>,
|
||||
|
||||
renderCustomStepsProgress: (
|
||||
build: ?Build,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string
|
||||
) => (
|
||||
<WebProjectLink
|
||||
renderCustomStepsProgress: ({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
}) => (
|
||||
<OnlineGameLink
|
||||
build={build}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
errored={errored}
|
||||
exportStep={exportStep}
|
||||
getGameThumbnailUrl={getGameThumbnailUrl}
|
||||
/>
|
||||
),
|
||||
|
||||
|
@@ -216,14 +216,14 @@ export default ({
|
||||
<Line expand justifyContent="flex-end">
|
||||
{game && !!build.s3Key && !isBuildPublished && (
|
||||
<RaisedButton
|
||||
label={<Trans>Publish this build on Liluo</Trans>}
|
||||
label={<Trans>Publish this build on Liluo.io</Trans>}
|
||||
onClick={() => onUpdatePublicBuild(build.id)}
|
||||
disabled={gameUpdating}
|
||||
/>
|
||||
)}
|
||||
{game && !!build.s3Key && isBuildPublished && (
|
||||
<FlatButton
|
||||
label={<Trans>Unpublish this build from Liluo</Trans>}
|
||||
label={<Trans>Unpublish this build from Liluo.io</Trans>}
|
||||
onClick={() => onUpdatePublicBuild(null)}
|
||||
disabled={gameUpdating}
|
||||
/>
|
||||
|
@@ -41,6 +41,7 @@ type ExportHomeProps = {|
|
||||
setChosenExporterSection: (section: ExporterSection) => void,
|
||||
cantExportBecauseOffline: boolean,
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
onChangeSubscription: () => void,
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
isNavigationDisabled: boolean,
|
||||
@@ -54,6 +55,7 @@ const ExportHome = ({
|
||||
setChosenExporterSection,
|
||||
cantExportBecauseOffline,
|
||||
project,
|
||||
onSaveProject,
|
||||
onChangeSubscription,
|
||||
authenticatedUser,
|
||||
isNavigationDisabled,
|
||||
@@ -77,6 +79,7 @@ const ExportHome = ({
|
||||
<ExportLauncher
|
||||
exportPipeline={onlineWebExporter.exportPipeline}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
onChangeSubscription={onChangeSubscription}
|
||||
authenticatedUser={authenticatedUser}
|
||||
setIsNavigationDisabled={setIsNavigationDisabled}
|
||||
|
@@ -23,7 +23,6 @@ import BuildStepsProgress, {
|
||||
import {
|
||||
registerGame,
|
||||
getGame,
|
||||
updateGame,
|
||||
type Game,
|
||||
setGameUserAcls,
|
||||
getAclsFromUserIds,
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
addCreateBadgePreHookIfNotClaimed,
|
||||
TRIVIAL_FIRST_WEB_EXPORT,
|
||||
} from '../../Utils/GDevelopServices/Badge';
|
||||
import { getWebBuildThumbnailUrl } from '../../Utils/GDevelopServices/Build';
|
||||
|
||||
type State = {|
|
||||
exportStep: BuildStep,
|
||||
@@ -50,6 +48,7 @@ type State = {|
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
onChangeSubscription: () => void,
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
exportPipeline: ExportPipeline<any, any, any, any, any>,
|
||||
@@ -157,30 +156,22 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
registerAndUpdateGame = async () => {
|
||||
registerGameIfNot = async () => {
|
||||
const profile = this.props.authenticatedUser.profile;
|
||||
const getAuthorizationHeader = this.props.authenticatedUser
|
||||
.getAuthorizationHeader;
|
||||
const gameId = this.props.project.getProjectUuid();
|
||||
const authorName =
|
||||
this.props.project.getAuthor() || 'Unspecified publisher';
|
||||
const gameName = this.props.project.getName() || 'Untitled game';
|
||||
if (profile) {
|
||||
const userId = profile.id;
|
||||
try {
|
||||
// Try to fetch the game to see if it's registered.
|
||||
// Try to fetch the game to see if it's registered but do not do anything with it.
|
||||
await getGame(getAuthorizationHeader, userId, gameId);
|
||||
// Update the game details to ensure that it is up to date in GDevelop services.
|
||||
const game = await updateGame(getAuthorizationHeader, userId, gameId, {
|
||||
authorName,
|
||||
gameName,
|
||||
});
|
||||
// We don't await for the authors update, as it is not required for publishing.
|
||||
this.tryUpdateAuthors();
|
||||
this.props.onGameUpdated(game);
|
||||
} catch (err) {
|
||||
if (err.response.status === 404) {
|
||||
// If the game is not registered, register it before launching the export.
|
||||
const authorName =
|
||||
this.props.project.getAuthor() || 'Unspecified publisher';
|
||||
const gameName = this.props.project.getName() || 'Untitled game';
|
||||
const game = await registerGame(getAuthorizationHeader, userId, {
|
||||
gameId,
|
||||
authorName,
|
||||
@@ -253,7 +244,7 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
try {
|
||||
setStep('register');
|
||||
// We await for this call, allowing to link the build to the game just registered.
|
||||
await this.registerAndUpdateGame();
|
||||
await this.registerGameIfNot();
|
||||
} catch {
|
||||
// But if it fails, we don't prevent building the game.
|
||||
console.warn('Error while registering the game - ignoring it.');
|
||||
@@ -342,7 +333,12 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
doneFooterOpen,
|
||||
exportState,
|
||||
} = this.state;
|
||||
const { project, authenticatedUser, exportPipeline } = this.props;
|
||||
const {
|
||||
project,
|
||||
authenticatedUser,
|
||||
exportPipeline,
|
||||
onSaveProject,
|
||||
} = this.props;
|
||||
if (!project) return null;
|
||||
const getBuildLimit = (authenticatedUser: AuthenticatedUser): ?Limit =>
|
||||
authenticatedUser.limits && exportPipeline.onlineBuildType
|
||||
@@ -411,14 +407,13 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
)}
|
||||
{authenticatedUser.authenticated &&
|
||||
(exportPipeline.renderCustomStepsProgress ? (
|
||||
exportPipeline.renderCustomStepsProgress(
|
||||
exportPipeline.renderCustomStepsProgress({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
buildId =>
|
||||
this.props.project &&
|
||||
getWebBuildThumbnailUrl(this.props.project, buildId)
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Line expand>
|
||||
<BuildStepsProgress
|
||||
|
@@ -43,6 +43,7 @@ export type Exporter = {|
|
||||
|
||||
export type ExportDialogWithoutExportsProps = {|
|
||||
project: ?gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
onClose: () => void,
|
||||
onChangeSubscription: () => void,
|
||||
|};
|
||||
@@ -57,6 +58,7 @@ type Props = {|
|
||||
|
||||
const ExportDialog = ({
|
||||
project,
|
||||
onSaveProject,
|
||||
onClose,
|
||||
allExportersRequireOnline,
|
||||
onChangeSubscription,
|
||||
@@ -197,6 +199,7 @@ const ExportDialog = ({
|
||||
setChosenExporterKey={setChosenExporterKey}
|
||||
setChosenExporterSection={setChosenExporterSection}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
onChangeSubscription={onChangeSubscription}
|
||||
authenticatedUser={authenticatedUser}
|
||||
isNavigationDisabled={isNavigationDisabled}
|
||||
@@ -233,6 +236,7 @@ const ExportDialog = ({
|
||||
<ExportLauncher
|
||||
exportPipeline={exporter.exportPipeline}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
onChangeSubscription={onChangeSubscription}
|
||||
authenticatedUser={authenticatedUser}
|
||||
key={chosenExporterKey}
|
||||
|
@@ -46,12 +46,13 @@ export type ExportPipeline<
|
||||
|
||||
isNavigationDisabled: (exportStep: BuildStep, errored: boolean) => boolean,
|
||||
|
||||
renderCustomStepsProgress?: (
|
||||
renderCustomStepsProgress?: ({
|
||||
build: ?Build,
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string
|
||||
) => React.Node,
|
||||
}) => React.Node,
|
||||
|
||||
prepareExporter: (
|
||||
context: ExportPipelineContext<ExportState>
|
||||
|
@@ -1,345 +0,0 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { t } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import Text from '../../UI/Text';
|
||||
import { Column, Line } from '../../UI/Grid';
|
||||
import TextField from '../../UI/TextField';
|
||||
import {
|
||||
getBuildArtifactUrl,
|
||||
type Build,
|
||||
} from '../../Utils/GDevelopServices/Build';
|
||||
import { type BuildStep } from '../Builds/BuildStepsProgress';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import Window from '../../Utils/Window';
|
||||
import Copy from '../../UI/CustomSvgIcons/Copy';
|
||||
import Share from '@material-ui/icons/Share';
|
||||
import InfoBar from '../../UI/Messages/InfoBar';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import { CircularProgress, LinearProgress } from '@material-ui/core';
|
||||
import FlatButton from '../../UI/FlatButton';
|
||||
import Dialog from '../../UI/Dialog';
|
||||
import {
|
||||
EmailShareButton,
|
||||
FacebookShareButton,
|
||||
RedditShareButton,
|
||||
TwitterShareButton,
|
||||
WhatsappShareButton,
|
||||
EmailIcon,
|
||||
FacebookIcon,
|
||||
RedditIcon,
|
||||
TwitterIcon,
|
||||
WhatsappIcon,
|
||||
} from 'react-share';
|
||||
import { TextFieldWithButtonLayout } from '../../UI/Layout';
|
||||
import {
|
||||
getGame,
|
||||
getGameUrl,
|
||||
updateGame,
|
||||
type Game,
|
||||
} from '../../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
|
||||
const styles = {
|
||||
icon: {
|
||||
padding: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const ExplanationHeader = () => (
|
||||
<Column noMargin alignItems="center" justifyContent="center">
|
||||
<Line>
|
||||
<Text align="center">
|
||||
<Trans>
|
||||
Generate a unique link, playable from any computer or mobile phone's
|
||||
browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
);
|
||||
|
||||
type WebProjectLinkProps = {|
|
||||
build: ?Build,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string,
|
||||
|};
|
||||
|
||||
export const WebProjectLink = ({
|
||||
build,
|
||||
errored,
|
||||
exportStep,
|
||||
getGameThumbnailUrl,
|
||||
}: WebProjectLinkProps) => {
|
||||
const [showCopiedInfoBar, setShowCopiedInfoBar] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [game, setGame] = React.useState<?Game>(null);
|
||||
const [isGameLoading, setIsGameLoading] = React.useState<boolean>(false);
|
||||
const { getAuthorizationHeader, profile } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const exportPending = !errored && exportStep !== '' && exportStep !== 'done';
|
||||
const isBuildComplete = build && build.status === 'complete';
|
||||
const isBuildPublished = build && game && build.id === game.publicWebBuildId;
|
||||
const gameUrl = getGameUrl(game);
|
||||
const buildUrl =
|
||||
exportPending || !isBuildComplete
|
||||
? null
|
||||
: isBuildPublished
|
||||
? gameUrl
|
||||
: getBuildArtifactUrl(build, 's3Key');
|
||||
|
||||
const loadGame = React.useCallback(
|
||||
async () => {
|
||||
const gameId = build && build.gameId;
|
||||
if (!profile || !gameId) return;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const game = await getGame(getAuthorizationHeader, id, gameId);
|
||||
setGame(game);
|
||||
setIsGameLoading(false);
|
||||
} catch (err) {
|
||||
setIsGameLoading(false);
|
||||
console.error('Unable to load the game', err);
|
||||
}
|
||||
},
|
||||
[build, getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Load game only once
|
||||
if (!game && isBuildComplete) {
|
||||
loadGame();
|
||||
}
|
||||
},
|
||||
[game, loadGame, isBuildComplete]
|
||||
);
|
||||
|
||||
const onOpen = () => {
|
||||
if (!buildUrl) return;
|
||||
Window.openExternalURL(buildUrl);
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
if (!buildUrl) return;
|
||||
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
|
||||
navigator.clipboard.writeText(buildUrl);
|
||||
setShowCopiedInfoBar(true);
|
||||
};
|
||||
|
||||
const onShare = async () => {
|
||||
if (!buildUrl || !navigator.share) return;
|
||||
|
||||
// We are on mobile (or on browsers supporting sharing using the system dialog).
|
||||
const shareData = {
|
||||
title: 'My GDevelop game',
|
||||
text: 'Try the game I just created with #gdevelop',
|
||||
url: buildUrl,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
} catch (err) {
|
||||
console.error("Couldn't share the game", err);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (exportStep === 'done') {
|
||||
setIsShareDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[exportStep]
|
||||
);
|
||||
|
||||
const onUpdatePublicBuild = React.useCallback(
|
||||
async () => {
|
||||
if (!profile || !game || !build) return;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const updatedGame = await updateGame(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
game.id,
|
||||
{
|
||||
publicWebBuildId: build.id,
|
||||
thumbnailUrl: build.id ? getGameThumbnailUrl(build.id) : undefined,
|
||||
}
|
||||
);
|
||||
setGame(updatedGame);
|
||||
setIsGameLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Unable to update the game', err);
|
||||
setIsGameLoading(false);
|
||||
}
|
||||
},
|
||||
[game, getAuthorizationHeader, profile, build, getGameThumbnailUrl]
|
||||
);
|
||||
|
||||
if (!build && !exportStep) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{exportPending && (
|
||||
<>
|
||||
<Text>
|
||||
<Trans>Just a few seconds while we generate the link...</Trans>
|
||||
</Text>
|
||||
<LinearProgress />
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
title={<Trans>Share your game</Trans>}
|
||||
actions={[
|
||||
<FlatButton
|
||||
key="close"
|
||||
label={<Trans>Back</Trans>}
|
||||
primary={false}
|
||||
onClick={() => setIsShareDialogOpen(false)}
|
||||
/>,
|
||||
]}
|
||||
open={isShareDialogOpen}
|
||||
onRequestClose={() => setIsShareDialogOpen(false)}
|
||||
>
|
||||
{buildUrl && !isGameLoading ? (
|
||||
<Column noMargin>
|
||||
<TextFieldWithButtonLayout
|
||||
noFloatingLabelText
|
||||
renderTextField={() => (
|
||||
<TextField
|
||||
value={buildUrl}
|
||||
readOnly
|
||||
fullWidth
|
||||
endAdornment={
|
||||
<IconButton onClick={onCopy} tooltip={t`Copy`} edge="end">
|
||||
<Copy />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderButton={style => (
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Open</Trans>}
|
||||
onClick={onOpen}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isBuildPublished && navigator.share && (
|
||||
<Line justifyContent="flex-end">
|
||||
<FlatButton
|
||||
label={<Trans>Share</Trans>}
|
||||
onClick={onShare}
|
||||
icon={<Share />}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{isBuildPublished && !navigator.share && (
|
||||
<Line justifyContent="space-between">
|
||||
<Column justifyContent="center">
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
This link is unique to your game. Show what you made to
|
||||
the community!
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Column>
|
||||
<Column justifyContent="flex-end">
|
||||
<Line>
|
||||
<FacebookShareButton
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
quote={`Try the game I just created with GDevelop.io`}
|
||||
hashtag="#gdevelop"
|
||||
>
|
||||
<FacebookIcon size={32} round />
|
||||
</FacebookShareButton>
|
||||
<RedditShareButton
|
||||
url={buildUrl}
|
||||
title={`Try the game I just created with r/gdevelop`}
|
||||
style={styles.icon}
|
||||
>
|
||||
<RedditIcon size={32} round />
|
||||
</RedditShareButton>
|
||||
<TwitterShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
hashtags={['gdevelop']}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<TwitterIcon size={32} round />
|
||||
</TwitterShareButton>
|
||||
<WhatsappShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<WhatsappIcon size={32} round />
|
||||
</WhatsappShareButton>
|
||||
<EmailShareButton
|
||||
subject="My GDevelop game"
|
||||
body="Try the game I just created with GDevelop.io"
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<EmailIcon size={32} round />
|
||||
</EmailShareButton>
|
||||
</Line>
|
||||
</Column>
|
||||
</Line>
|
||||
)}
|
||||
{!isBuildPublished && game && (
|
||||
<Line>
|
||||
<AlertMessage
|
||||
kind="info"
|
||||
renderRightButton={() => (
|
||||
<RaisedButton
|
||||
label={<Trans>Update your game</Trans>}
|
||||
onClick={onUpdatePublicBuild}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Trans>
|
||||
This link is private so you can share it with friends and
|
||||
testers. When you're ready you can update your Liluo.io game
|
||||
page.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
)}
|
||||
</Column>
|
||||
) : (
|
||||
<Column alignItems="center">
|
||||
<CircularProgress />
|
||||
</Column>
|
||||
)}
|
||||
<InfoBar
|
||||
message={<Trans>Copied to clipboard!</Trans>}
|
||||
visible={showCopiedInfoBar}
|
||||
hide={() => setShowCopiedInfoBar(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const onlineWebExporter = {
|
||||
key: 'onlinewebexport',
|
||||
tabName: 'Web',
|
||||
name: <Trans>Web</Trans>,
|
||||
helpPage: '/publishing/web',
|
||||
};
|
@@ -0,0 +1,431 @@
|
||||
// @flow
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import TextField from '../../../UI/TextField';
|
||||
import {
|
||||
getBuildArtifactUrl,
|
||||
getWebBuildThumbnailUrl,
|
||||
type Build,
|
||||
} from '../../../Utils/GDevelopServices/Build';
|
||||
import { type BuildStep } from '../../Builds/BuildStepsProgress';
|
||||
import RaisedButton from '../../../UI/RaisedButton';
|
||||
import Window from '../../../Utils/Window';
|
||||
import Copy from '../../../UI/CustomSvgIcons/Copy';
|
||||
import Share from '@material-ui/icons/Share';
|
||||
import InfoBar from '../../../UI/Messages/InfoBar';
|
||||
import IconButton from '../../../UI/IconButton';
|
||||
import { CircularProgress, LinearProgress } from '@material-ui/core';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import Dialog from '../../../UI/Dialog';
|
||||
import {
|
||||
EmailShareButton,
|
||||
FacebookShareButton,
|
||||
RedditShareButton,
|
||||
TwitterShareButton,
|
||||
WhatsappShareButton,
|
||||
EmailIcon,
|
||||
FacebookIcon,
|
||||
RedditIcon,
|
||||
TwitterIcon,
|
||||
WhatsappIcon,
|
||||
} from 'react-share';
|
||||
import { TextFieldWithButtonLayout } from '../../../UI/Layout';
|
||||
import {
|
||||
getGame,
|
||||
getGameUrl,
|
||||
updateGame,
|
||||
setGameSlug,
|
||||
getGameSlugs,
|
||||
type Game,
|
||||
type GameSlug,
|
||||
} from '../../../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import AlertMessage from '../../../UI/AlertMessage';
|
||||
import OnlineGamePropertiesDialog from './OnlineGamePropertiesDialog';
|
||||
import { showErrorBox } from '../../../UI/Messages/MessageBox';
|
||||
import { type PartialGameChange } from '../../../GameDashboard/PublicGamePropertiesDialog';
|
||||
|
||||
const styles = {
|
||||
icon: {
|
||||
padding: 5,
|
||||
},
|
||||
};
|
||||
|
||||
type OnlineGameLinkProps = {|
|
||||
build: ?Build,
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
|};
|
||||
|
||||
const OnlineGameLink = ({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
}: OnlineGameLinkProps) => {
|
||||
const [showCopiedInfoBar, setShowCopiedInfoBar] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [
|
||||
isOnlineGamePropertiesDialogOpen,
|
||||
setIsOnlineGamePropertiesDialogOpen,
|
||||
] = React.useState<boolean>(false);
|
||||
const [game, setGame] = React.useState<?Game>(null);
|
||||
const [slug, setSlug] = React.useState<?GameSlug>(null);
|
||||
const [isGameLoading, setIsGameLoading] = React.useState<boolean>(false);
|
||||
const { getAuthorizationHeader, profile } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const exportPending = !errored && exportStep !== '' && exportStep !== 'done';
|
||||
const isBuildComplete = build && build.status === 'complete';
|
||||
const isBuildPublished = build && game && build.id === game.publicWebBuildId;
|
||||
const gameUrl = getGameUrl(game, slug);
|
||||
const buildUrl =
|
||||
exportPending || !isBuildComplete
|
||||
? null
|
||||
: isBuildPublished
|
||||
? gameUrl
|
||||
: getBuildArtifactUrl(build, 's3Key');
|
||||
|
||||
const loadGame = React.useCallback(
|
||||
async () => {
|
||||
const gameId = build && build.gameId;
|
||||
if (!profile || !gameId) return;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const [game, slugs] = await Promise.all([
|
||||
getGame(getAuthorizationHeader, id, gameId),
|
||||
getGameSlugs(getAuthorizationHeader, id, gameId).catch(err => {
|
||||
console.error('Unable to get the game slug', err);
|
||||
}),
|
||||
]);
|
||||
setGame(game);
|
||||
if (slugs && slugs.length > 0) {
|
||||
setSlug(slugs[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unable to load the game', err);
|
||||
} finally {
|
||||
setIsGameLoading(false);
|
||||
}
|
||||
},
|
||||
[build, getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Load game only once
|
||||
if (!game && isBuildComplete) {
|
||||
loadGame();
|
||||
}
|
||||
},
|
||||
[game, loadGame, isBuildComplete]
|
||||
);
|
||||
|
||||
const onOpen = () => {
|
||||
if (!buildUrl) return;
|
||||
Window.openExternalURL(buildUrl);
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
if (!buildUrl) return;
|
||||
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
|
||||
navigator.clipboard.writeText(buildUrl);
|
||||
setShowCopiedInfoBar(true);
|
||||
};
|
||||
|
||||
const onShare = async () => {
|
||||
if (!buildUrl || !navigator.share) return;
|
||||
|
||||
// We are on mobile (or on browsers supporting sharing using the system dialog).
|
||||
const shareData = {
|
||||
title: 'My GDevelop game',
|
||||
text: 'Try the game I just created with #gdevelop',
|
||||
url: buildUrl,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
} catch (err) {
|
||||
console.error("Couldn't share the game", err);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (exportStep === 'done') {
|
||||
setIsShareDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[exportStep]
|
||||
);
|
||||
|
||||
const onGameUpdate = React.useCallback(
|
||||
async (
|
||||
partialGameChange: PartialGameChange,
|
||||
i18n: I18nType
|
||||
): Promise<boolean> => {
|
||||
if (!profile || !game || !build) return false;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const updatedGame = await updateGame(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
game.id,
|
||||
{
|
||||
gameName: project.getName(),
|
||||
description: project.getDescription(),
|
||||
categories: project.getCategories().toJSArray(),
|
||||
playWithGamepad: project.isPlayableWithGamepad(),
|
||||
playWithKeyboard: project.isPlayableWithKeyboard(),
|
||||
playWithMobile: project.isPlayableWithMobile(),
|
||||
orientation: project.getOrientation(),
|
||||
publicWebBuildId: build.id,
|
||||
thumbnailUrl: getWebBuildThumbnailUrl(project, build.id),
|
||||
discoverable: partialGameChange.discoverable,
|
||||
}
|
||||
);
|
||||
setGame(updatedGame);
|
||||
const { userSlug, gameSlug } = partialGameChange;
|
||||
if (userSlug && gameSlug && userSlug === profile.username) {
|
||||
try {
|
||||
await setGameSlug(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
game.id,
|
||||
userSlug,
|
||||
gameSlug
|
||||
);
|
||||
setSlug({ username: userSlug, gameSlug: gameSlug, createdAt: 0 });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Unable to update the game slug:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(
|
||||
t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.`
|
||||
) +
|
||||
' ' +
|
||||
i18n._(t`Verify your internet connection or try again later.`),
|
||||
rawError: error,
|
||||
errorId: 'game-slug-update-error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorBox({
|
||||
message: i18n._(
|
||||
t`There was an error updating your game. Verify that your internet connection is working or try again later.`
|
||||
),
|
||||
rawError: err,
|
||||
errorId: 'update-game-error',
|
||||
});
|
||||
console.error('Unable to update the game', err);
|
||||
return false;
|
||||
} finally {
|
||||
setIsGameLoading(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[game, getAuthorizationHeader, profile, build, project]
|
||||
);
|
||||
|
||||
if (!build && !exportStep) return null;
|
||||
|
||||
const dialogActions = [
|
||||
<FlatButton
|
||||
key="close"
|
||||
label={<Trans>Close</Trans>}
|
||||
primary={false}
|
||||
onClick={() => setIsShareDialogOpen(false)}
|
||||
/>,
|
||||
// Ensure there is a game loaded, meaning the user owns the game.
|
||||
game && buildUrl && !isBuildPublished && (
|
||||
<RaisedButton
|
||||
key="publish"
|
||||
label={<Trans>Verify and Publish to Liluo.io</Trans>}
|
||||
primary
|
||||
onClick={() => setIsOnlineGamePropertiesDialogOpen(true)}
|
||||
/>
|
||||
),
|
||||
];
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<>
|
||||
{exportPending && (
|
||||
<>
|
||||
<Text>
|
||||
<Trans>Just a few seconds while we generate the link...</Trans>
|
||||
</Text>
|
||||
<LinearProgress />
|
||||
</>
|
||||
)}
|
||||
{isShareDialogOpen && (
|
||||
<Dialog
|
||||
title={<Trans>Share your game</Trans>}
|
||||
actions={dialogActions}
|
||||
open
|
||||
onRequestClose={() => setIsShareDialogOpen(false)}
|
||||
>
|
||||
{buildUrl && !isGameLoading ? (
|
||||
<Column noMargin>
|
||||
<TextFieldWithButtonLayout
|
||||
noFloatingLabelText
|
||||
renderTextField={() => (
|
||||
<TextField
|
||||
value={buildUrl}
|
||||
readOnly
|
||||
fullWidth
|
||||
endAdornment={
|
||||
<IconButton
|
||||
onClick={onCopy}
|
||||
tooltip={t`Copy`}
|
||||
edge="end"
|
||||
>
|
||||
<Copy />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderButton={style => (
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Open</Trans>}
|
||||
onClick={onOpen}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isBuildPublished && navigator.share && (
|
||||
<Line justifyContent="flex-end">
|
||||
<FlatButton
|
||||
label={<Trans>Share</Trans>}
|
||||
onClick={onShare}
|
||||
icon={<Share />}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{isBuildPublished && !navigator.share && (
|
||||
<Line justifyContent="space-between">
|
||||
<Column justifyContent="center">
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
Your game is published! Share it with the community!
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Column>
|
||||
<Column justifyContent="flex-end">
|
||||
<Line>
|
||||
<FacebookShareButton
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
quote={`Try the game I just created with GDevelop.io`}
|
||||
hashtag="#gdevelop"
|
||||
>
|
||||
<FacebookIcon size={32} round />
|
||||
</FacebookShareButton>
|
||||
<RedditShareButton
|
||||
url={buildUrl}
|
||||
title={`Try the game I just created with r/gdevelop`}
|
||||
style={styles.icon}
|
||||
>
|
||||
<RedditIcon size={32} round />
|
||||
</RedditShareButton>
|
||||
<TwitterShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
hashtags={['gdevelop']}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<TwitterIcon size={32} round />
|
||||
</TwitterShareButton>
|
||||
<WhatsappShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<WhatsappIcon size={32} round />
|
||||
</WhatsappShareButton>
|
||||
<EmailShareButton
|
||||
subject="My GDevelop game"
|
||||
body="Try the game I just created with GDevelop.io"
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<EmailIcon size={32} round />
|
||||
</EmailShareButton>
|
||||
</Line>
|
||||
</Column>
|
||||
</Line>
|
||||
)}
|
||||
{!isBuildPublished && game && (
|
||||
<Line>
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
This link is private so you can share it with friends
|
||||
and testers. When you're ready you can update your
|
||||
Liluo.io game page.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
)}
|
||||
</Column>
|
||||
) : (
|
||||
<Column alignItems="center">
|
||||
<CircularProgress />
|
||||
</Column>
|
||||
)}
|
||||
<InfoBar
|
||||
message={<Trans>Copied to clipboard!</Trans>}
|
||||
visible={showCopiedInfoBar}
|
||||
hide={() => setShowCopiedInfoBar(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{game && build && isOnlineGamePropertiesDialogOpen && (
|
||||
<OnlineGamePropertiesDialog
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
buildId={build.id}
|
||||
onClose={() => setIsOnlineGamePropertiesDialogOpen(false)}
|
||||
onApply={async partialGameChange => {
|
||||
const isGameUpdated = await onGameUpdate(
|
||||
partialGameChange,
|
||||
i18n
|
||||
);
|
||||
if (isGameUpdated) {
|
||||
setIsOnlineGamePropertiesDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
game={game}
|
||||
slug={slug}
|
||||
isLoading={isGameLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineGameLink;
|
@@ -0,0 +1,161 @@
|
||||
// @flow
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type Game, type GameSlug } from '../../../Utils/GDevelopServices/Game';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import Dialog from '../../../UI/Dialog';
|
||||
import {
|
||||
cleanUpGameSlug,
|
||||
PublicGameProperties,
|
||||
} from '../../../GameDashboard/PublicGameProperties';
|
||||
import {
|
||||
applyPublicPropertiesToProject,
|
||||
type PartialGameChange,
|
||||
} from '../../../GameDashboard/PublicGamePropertiesDialog';
|
||||
import { getWebBuildThumbnailUrl } from '../../../Utils/GDevelopServices/Build';
|
||||
import RaisedButtonWithSplitMenu from '../../../UI/RaisedButtonWithSplitMenu';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
buildId: string,
|
||||
game: Game,
|
||||
slug: ?GameSlug,
|
||||
onClose: () => void,
|
||||
onApply: PartialGameChange => Promise<void>,
|
||||
isLoading: boolean,
|
||||
|};
|
||||
|
||||
export const OnlineGamePropertiesDialog = ({
|
||||
project,
|
||||
onSaveProject,
|
||||
buildId,
|
||||
game,
|
||||
slug,
|
||||
onClose,
|
||||
onApply,
|
||||
isLoading,
|
||||
}: Props) => {
|
||||
const { profile } = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const [name, setName] = React.useState<string>(project.getName());
|
||||
const [categories, setCategories] = React.useState<string[]>(
|
||||
project.getCategories().toJSArray()
|
||||
);
|
||||
const [description, setDescription] = React.useState<string>(
|
||||
project.getDescription()
|
||||
);
|
||||
const [authorIds, setAuthorIds] = React.useState<string[]>(
|
||||
project.getAuthorIds().toJSArray()
|
||||
);
|
||||
const [playWithKeyboard, setPlayableWithKeyboard] = React.useState<boolean>(
|
||||
project.isPlayableWithKeyboard()
|
||||
);
|
||||
const [playWithGamepad, setPlayableWithGamepad] = React.useState<boolean>(
|
||||
project.isPlayableWithGamepad()
|
||||
);
|
||||
const [playWithMobile, setPlayableWithMobile] = React.useState<boolean>(
|
||||
project.isPlayableWithMobile()
|
||||
);
|
||||
const [userSlug, setUserSlug] = React.useState<string>(
|
||||
(slug && slug.username) || (profile && profile.username) || ''
|
||||
);
|
||||
const [gameSlug, setGameSlug] = React.useState<string>(
|
||||
(slug && slug.gameSlug) || cleanUpGameSlug(project.getName())
|
||||
);
|
||||
const [orientation, setOrientation] = React.useState<string>(
|
||||
project.getOrientation()
|
||||
);
|
||||
const [discoverable, setDiscoverable] = React.useState<boolean>(
|
||||
!!game.discoverable
|
||||
);
|
||||
const thumbnailUrl = getWebBuildThumbnailUrl(project, buildId);
|
||||
|
||||
const saveProjectAndPublish = async () => {
|
||||
await onSaveProject();
|
||||
await onPublish();
|
||||
};
|
||||
|
||||
const onPublish = async () => {
|
||||
// Update the project with the new properties before updating the game.
|
||||
if (
|
||||
applyPublicPropertiesToProject(project, {
|
||||
name,
|
||||
categories: categories || [],
|
||||
description: description || '',
|
||||
authorIds,
|
||||
playWithKeyboard: !!playWithKeyboard,
|
||||
playWithGamepad: !!playWithGamepad,
|
||||
playWithMobile: !!playWithMobile,
|
||||
orientation: orientation || 'default',
|
||||
})
|
||||
) {
|
||||
await onApply({ discoverable, userSlug, gameSlug });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={<Trans>Verify your game info before publishing</Trans>}
|
||||
onRequestClose={onClose}
|
||||
actions={[
|
||||
<FlatButton
|
||||
label={<Trans>Back</Trans>}
|
||||
key="back"
|
||||
primary={false}
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
<RaisedButtonWithSplitMenu
|
||||
label={<Trans>Save project and publish</Trans>}
|
||||
key="publish"
|
||||
primary
|
||||
onClick={() => {
|
||||
saveProjectAndPublish();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
buildMenuTemplate={i18n => [
|
||||
{
|
||||
label: i18n._(t`Publish without saving project`),
|
||||
click: onPublish,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
]}
|
||||
cannotBeDismissed={isLoading}
|
||||
open
|
||||
>
|
||||
<PublicGameProperties
|
||||
name={name}
|
||||
setName={setName}
|
||||
categories={categories}
|
||||
setCategories={setCategories}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
project={project}
|
||||
authorIds={authorIds}
|
||||
setAuthorIds={setAuthorIds}
|
||||
setPlayableWithKeyboard={setPlayableWithKeyboard}
|
||||
playWithKeyboard={playWithKeyboard}
|
||||
setPlayableWithGamepad={setPlayableWithGamepad}
|
||||
playWithGamepad={playWithGamepad}
|
||||
setPlayableWithMobile={setPlayableWithMobile}
|
||||
playWithMobile={playWithMobile}
|
||||
setOrientation={setOrientation}
|
||||
orientation={orientation}
|
||||
userSlug={userSlug}
|
||||
setUserSlug={setUserSlug}
|
||||
gameSlug={gameSlug}
|
||||
setGameSlug={setGameSlug}
|
||||
discoverable={discoverable}
|
||||
setDiscoverable={setDiscoverable}
|
||||
displayThumbnail
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineGamePropertiesDialog;
|
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import OnlineGameLink from './OnlineGameLink';
|
||||
|
||||
const ExplanationHeader = () => (
|
||||
<Column noMargin alignItems="center" justifyContent="center">
|
||||
<Line>
|
||||
<Text align="center">
|
||||
<Trans>
|
||||
Generate a unique link, playable from any computer or mobile phone's
|
||||
browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
);
|
||||
|
||||
const onlineWebExporter = {
|
||||
key: 'onlinewebexport',
|
||||
tabName: 'Web',
|
||||
name: <Trans>Web</Trans>,
|
||||
helpPage: '/publishing/web',
|
||||
};
|
||||
|
||||
export { onlineWebExporter, ExplanationHeader, OnlineGameLink };
|
@@ -17,10 +17,9 @@ import {
|
||||
type ExportPipeline,
|
||||
type ExportPipelineContext,
|
||||
} from '../ExportPipeline.flow';
|
||||
import { type BuildStep } from '../Builds/BuildStepsProgress';
|
||||
import {
|
||||
ExplanationHeader,
|
||||
WebProjectLink,
|
||||
OnlineGameLink,
|
||||
} from '../GenericExporters/OnlineWebExport';
|
||||
const path = optionalRequire('path');
|
||||
const os = optionalRequire('os');
|
||||
@@ -67,17 +66,19 @@ export const localOnlineWebExportPipeline: ExportPipeline<
|
||||
|
||||
renderLaunchButtonLabel: () => <Trans>Generate link</Trans>,
|
||||
|
||||
renderCustomStepsProgress: (
|
||||
build: ?Build,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string
|
||||
) => (
|
||||
<WebProjectLink
|
||||
renderCustomStepsProgress: ({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
}) => (
|
||||
<OnlineGameLink
|
||||
build={build}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
errored={errored}
|
||||
exportStep={exportStep}
|
||||
getGameThumbnailUrl={getGameThumbnailUrl}
|
||||
/>
|
||||
),
|
||||
|
||||
|
68
newIDE/app/src/GameDashboard/CyrillicToLatin.json
Normal file
68
newIDE/app/src/GameDashboard/CyrillicToLatin.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"А": "A",
|
||||
"Б": "B",
|
||||
"В": "V",
|
||||
"Г": "G",
|
||||
"Д": "D",
|
||||
"Е": "E",
|
||||
"Ё": "E",
|
||||
"Ж": "Zh",
|
||||
"З": "Z",
|
||||
"И": "I",
|
||||
"Й": "J",
|
||||
"К": "K",
|
||||
"Л": "L",
|
||||
"М": "M",
|
||||
"Н": "N",
|
||||
"О": "O",
|
||||
"П": "P",
|
||||
"Р": "R",
|
||||
"С": "S",
|
||||
"Т": "T",
|
||||
"У": "U",
|
||||
"Ф": "F",
|
||||
"Х": "H",
|
||||
"Ц": "Ts",
|
||||
"Ч": "Ch",
|
||||
"Ш": "Sh",
|
||||
"Щ": "Shch",
|
||||
"Ы": "Y",
|
||||
"Э": "E",
|
||||
"Ю": "Yu",
|
||||
"Я": "Ya",
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"й": "j",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "ts",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "shch",
|
||||
"ы": "y",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
"Ъ": "",
|
||||
"ъ": "",
|
||||
"Ь": "",
|
||||
"ь": ""
|
||||
}
|
@@ -1,45 +1,22 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Card, CardActions, CardHeader, Chip, Paper } from '@material-ui/core';
|
||||
import { Card, CardActions, CardHeader, Chip } from '@material-ui/core';
|
||||
import * as React from 'react';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import { getGameUrl, type Game } from '../Utils/GDevelopServices/Game';
|
||||
import TimelineIcon from '@material-ui/icons/Timeline';
|
||||
import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay';
|
||||
import TuneIcon from '@material-ui/icons/Tune';
|
||||
import { ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import Window from '../Utils/Window';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
|
||||
const styles = {
|
||||
image: {
|
||||
display: 'block',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
thumbnail: {
|
||||
width: 240,
|
||||
height: 135,
|
||||
},
|
||||
};
|
||||
import { GameThumbnail } from './GameThumbnail';
|
||||
|
||||
type Props = {|
|
||||
game: Game,
|
||||
isCurrentGame: boolean,
|
||||
onOpenDetails: () => void,
|
||||
onOpenBuilds: () => void,
|
||||
onOpenAnalytics: () => void,
|
||||
onOpenGameManager: () => void,
|
||||
|};
|
||||
|
||||
export const GameCard = ({
|
||||
game,
|
||||
isCurrentGame,
|
||||
onOpenDetails,
|
||||
onOpenBuilds,
|
||||
onOpenAnalytics,
|
||||
}: Props) => {
|
||||
export const GameCard = ({ game, isCurrentGame, onOpenGameManager }: Props) => {
|
||||
const openGameUrl = () => {
|
||||
const url = getGameUrl(game);
|
||||
if (!url) return;
|
||||
@@ -49,31 +26,11 @@ export const GameCard = ({
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Card key={game.id}>
|
||||
<Line>
|
||||
{game.thumbnailUrl ? (
|
||||
<img
|
||||
src={game.thumbnailUrl}
|
||||
style={{
|
||||
...styles.image,
|
||||
...styles.thumbnail,
|
||||
}}
|
||||
alt={game.gameName}
|
||||
title={game.gameName}
|
||||
/>
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
style={{
|
||||
...styles.thumbnail,
|
||||
whiteSpace: 'normal',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<EmptyMessage>
|
||||
<Trans>No thumbnail set</Trans>
|
||||
</EmptyMessage>
|
||||
</Paper>
|
||||
)}
|
||||
<ResponsiveLineStackLayout>
|
||||
<GameThumbnail
|
||||
gameName={game.gameName}
|
||||
thumbnailUrl={game.thumbnailUrl}
|
||||
/>
|
||||
<Column expand>
|
||||
<CardHeader
|
||||
title={game.gameName}
|
||||
@@ -95,7 +52,13 @@ export const GameCard = ({
|
||||
<Spacer />
|
||||
<Chip
|
||||
size="small"
|
||||
label={<Trans>Published on Liluo</Trans>}
|
||||
label={
|
||||
game.discoverable ? (
|
||||
<Trans>Discoverable on Liluo.io</Trans>
|
||||
) : (
|
||||
<Trans>Published on Liluo.io</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -110,30 +73,19 @@ export const GameCard = ({
|
||||
>
|
||||
{game.publicWebBuildId && (
|
||||
<RaisedButton
|
||||
label={<Trans>Open</Trans>}
|
||||
label={<Trans>Open in browser</Trans>}
|
||||
onClick={openGameUrl}
|
||||
primary
|
||||
/>
|
||||
)}
|
||||
<FlatButton
|
||||
icon={<TuneIcon />}
|
||||
label={<Trans>Details</Trans>}
|
||||
onClick={onOpenDetails}
|
||||
/>
|
||||
<FlatButton
|
||||
icon={<PlaylistPlayIcon />}
|
||||
label={<Trans>Builds</Trans>}
|
||||
onClick={onOpenBuilds}
|
||||
/>
|
||||
<FlatButton
|
||||
icon={<TimelineIcon />}
|
||||
label={<Trans>Analytics</Trans>}
|
||||
onClick={onOpenAnalytics}
|
||||
<RaisedButton
|
||||
label={<Trans>Manage game</Trans>}
|
||||
onClick={onOpenGameManager}
|
||||
primary
|
||||
/>
|
||||
</ResponsiveLineStackLayout>
|
||||
</CardActions>
|
||||
</Column>
|
||||
</Line>
|
||||
</ResponsiveLineStackLayout>
|
||||
</Card>
|
||||
)}
|
||||
</I18n>
|
||||
|
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
deleteGame,
|
||||
getPublicGame,
|
||||
setGameUserAcls,
|
||||
setGameSlug,
|
||||
getAclsFromUserIds,
|
||||
getCategoryName,
|
||||
} from '../Utils/GDevelopServices/Game';
|
||||
@@ -41,13 +41,14 @@ import PlaceholderLoader from '../UI/PlaceholderLoader';
|
||||
import {
|
||||
PublicGamePropertiesDialog,
|
||||
type PartialGameChange,
|
||||
} from '../ProjectManager/PublicGamePropertiesDialog';
|
||||
} from './PublicGamePropertiesDialog';
|
||||
import TextField from '../UI/TextField';
|
||||
import KeyboardIcon from '@material-ui/icons/Keyboard';
|
||||
import SportsEsportsIcon from '@material-ui/icons/SportsEsports';
|
||||
import SmartphoneIcon from '@material-ui/icons/Smartphone';
|
||||
import Crown from '../UI/CustomSvgIcons/Crown';
|
||||
import { showErrorBox } from '../UI/Messages/MessageBox';
|
||||
import { showErrorBox, showWarningBox } from '../UI/Messages/MessageBox';
|
||||
import LeaderboardAdmin from './LeaderboardAdmin';
|
||||
|
||||
const styles = {
|
||||
tableRowStatColumn: {
|
||||
@@ -55,7 +56,11 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export type GamesDetailsTab = 'details' | 'builds' | 'analytics';
|
||||
export type GamesDetailsTab =
|
||||
| 'details'
|
||||
| 'builds'
|
||||
| 'analytics'
|
||||
| 'leaderboards';
|
||||
|
||||
type Props = {|
|
||||
game: Game,
|
||||
@@ -81,10 +86,16 @@ export const GameDetailsDialog = ({
|
||||
const [gameRollingMetrics, setGameMetrics] = React.useState<?GameMetrics>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [
|
||||
gameUnregisterErrorText,
|
||||
setGameUnregisterErrorText,
|
||||
] = React.useState<?string>(null);
|
||||
const [gameRollingMetricsError, setGameMetricsError] = React.useState<?Error>(
|
||||
null
|
||||
);
|
||||
const [isGameMetricsLoading, setIsGameMetricsLoading] = React.useState(false);
|
||||
const [isGameUpdating, setIsGameUpdating] = React.useState(false);
|
||||
|
||||
const yesterdayIsoDate = formatISO(subDays(new Date(), 1), {
|
||||
representation: 'date',
|
||||
@@ -152,51 +163,113 @@ export const GameDetailsDialog = ({
|
||||
[loadPublicGame]
|
||||
);
|
||||
|
||||
const handleGameUpdated = React.useCallback(
|
||||
(updatedGame: Game) => {
|
||||
// Set Public Game to null to show the loader.
|
||||
// It will be refetched thanks to loadPublicGame, because Game is updated.
|
||||
setPublicGame(null);
|
||||
onGameUpdated(updatedGame);
|
||||
},
|
||||
[onGameUpdated]
|
||||
);
|
||||
|
||||
const updateGameFromProject = async (
|
||||
partialGameChange: PartialGameChange,
|
||||
i18n: I18nType
|
||||
) => {
|
||||
if (!project || !profile) return;
|
||||
): Promise<boolean> => {
|
||||
if (!project || !profile) return false;
|
||||
const { id } = profile;
|
||||
|
||||
// Set public game to null as it will be refetched automatically by the callback above.
|
||||
setPublicGame(null);
|
||||
const ownerIds = partialGameChange.ownerIds;
|
||||
if (!ownerIds || !ownerIds.length) {
|
||||
showWarningBox(
|
||||
i18n._(
|
||||
t`You must select at least one user to be the owner of the game.`
|
||||
),
|
||||
{ delayToNextTick: true }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGameUpdating(true);
|
||||
const gameId = project.getProjectUuid();
|
||||
const updatedGame = await updateGame(getAuthorizationHeader, id, gameId, {
|
||||
authorName: project.getAuthor() || 'Unspecified publisher',
|
||||
gameName: project.getName() || 'Untitle game',
|
||||
gameName: project.getName() || 'Untitled game',
|
||||
categories: project.getCategories().toJSArray() || [],
|
||||
description: project.getDescription() || '',
|
||||
playWithKeyboard: project.isPlayableWithKeyboard(),
|
||||
playWithGamepad: project.isPlayableWithGamepad(),
|
||||
playWithMobile: project.isPlayableWithMobile(),
|
||||
orientation: project.getOrientation(),
|
||||
// The thumbnailUrl is updated only when a build is made public.
|
||||
discoverable: partialGameChange.discoverable,
|
||||
});
|
||||
if (
|
||||
partialGameChange.userSlug &&
|
||||
partialGameChange.gameSlug &&
|
||||
partialGameChange.userSlug === profile.username
|
||||
) {
|
||||
try {
|
||||
await setGameSlug(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
gameId,
|
||||
partialGameChange.userSlug,
|
||||
partialGameChange.gameSlug
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Unable to update the game slug:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(
|
||||
t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.`
|
||||
) +
|
||||
' ' +
|
||||
i18n._(t`Verify your internet connection or try again later.`),
|
||||
rawError: error,
|
||||
errorId: 'game-slug-update-error',
|
||||
});
|
||||
setIsGameUpdating(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const authorAcls = getAclsFromUserIds(
|
||||
project.getAuthorIds().toJSArray()
|
||||
);
|
||||
const ownerAcls = getAclsFromUserIds(partialGameChange.ownerIds);
|
||||
const ownerAcls = getAclsFromUserIds(ownerIds);
|
||||
await setGameUserAcls(getAuthorizationHeader, id, gameId, {
|
||||
ownership: ownerAcls,
|
||||
author: authorAcls,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to update the game owners or authors:', error);
|
||||
console.error(
|
||||
'Unable to update the game owners or authors:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(t`Unable to update the game owners or authors.`) +
|
||||
i18n._(
|
||||
t`Unable to update the game owners or authors. Have you removed yourself from the owners?`
|
||||
) +
|
||||
' ' +
|
||||
i18n._(t`Verify your internet connection or try again later.`),
|
||||
rawError: error,
|
||||
errorId: 'game-acls-update-error',
|
||||
});
|
||||
setIsGameUpdating(false);
|
||||
return false;
|
||||
}
|
||||
onGameUpdated(updatedGame);
|
||||
handleGameUpdated(updatedGame);
|
||||
} catch (error) {
|
||||
console.error('Unable to update the game:', error);
|
||||
console.error(
|
||||
'Unable to update the game:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(t`Unable to update the game details.`) +
|
||||
@@ -205,18 +278,39 @@ export const GameDetailsDialog = ({
|
||||
rawError: error,
|
||||
errorId: 'game-details-update-error',
|
||||
});
|
||||
setIsGameUpdating(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsGameUpdating(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
const unregisterGame = async () => {
|
||||
const unregisterGame = async (i18n: I18nType) => {
|
||||
if (!profile) return;
|
||||
const { id } = profile;
|
||||
|
||||
setGameUnregisterErrorText(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setIsGameUpdating(true);
|
||||
await deleteGame(getAuthorizationHeader, id, game.id);
|
||||
onGameDeleted();
|
||||
} catch (error) {
|
||||
console.error('Unable to delete the game:', error);
|
||||
if (
|
||||
error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.code === 'game-deletion/leaderboards-exist'
|
||||
) {
|
||||
setGameUnregisterErrorText(
|
||||
i18n._(
|
||||
t`You cannot unregister a game that has active leaderboards. To delete them, go in the Leaderboards tab, and delete them one by one.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsGameUpdating(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,8 +320,7 @@ export const GameDetailsDialog = ({
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
// Set public game to null as it will be refetched automatically by the callback above.
|
||||
setPublicGame(null);
|
||||
setIsGameUpdating(true);
|
||||
const updatedGame = await updateGame(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
@@ -236,12 +329,14 @@ export const GameDetailsDialog = ({
|
||||
publicWebBuildId: null,
|
||||
}
|
||||
);
|
||||
onGameUpdated(updatedGame);
|
||||
handleGameUpdated(updatedGame);
|
||||
} catch (err) {
|
||||
console.error('Unable to update the game', err);
|
||||
} finally {
|
||||
setIsGameUpdating(false);
|
||||
}
|
||||
},
|
||||
[game, getAuthorizationHeader, profile, onGameUpdated]
|
||||
[game, getAuthorizationHeader, profile, handleGameUpdated]
|
||||
);
|
||||
|
||||
const authorUsernames =
|
||||
@@ -268,25 +363,41 @@ export const GameDetailsDialog = ({
|
||||
}
|
||||
open
|
||||
noMargin
|
||||
onRequestClose={onClose}
|
||||
flexColumnBody
|
||||
fullHeight={currentTab === 'leaderboards'}
|
||||
onRequestClose={() => {
|
||||
if (!isLoading) onClose();
|
||||
}}
|
||||
maxWidth="md"
|
||||
actions={[
|
||||
<FlatButton
|
||||
label={<Trans>Close</Trans>}
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
key="close"
|
||||
/>,
|
||||
]}
|
||||
secondaryActions={[
|
||||
<HelpButton key="help" helpPagePath="/interface/games-dashboard" />,
|
||||
<HelpButton
|
||||
key="help"
|
||||
helpPagePath={
|
||||
currentTab === 'leaderboards'
|
||||
? '/interface/games-dashboard/leaderboard-administration'
|
||||
: '/interface/games-dashboard'
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Tabs value={currentTab} onChange={setCurrentTab}>
|
||||
<Tab label={<Trans>Details</Trans>} value="details" />
|
||||
<Tab label={<Trans>Builds</Trans>} value="builds" />
|
||||
<Tab label={<Trans>Analytics</Trans>} value="analytics" />
|
||||
<Tab label={<Trans>Leaderboards</Trans>} value="leaderboards" />
|
||||
</Tabs>
|
||||
<Line>
|
||||
<Line expand>
|
||||
{currentTab === 'leaderboards' ? (
|
||||
<LeaderboardAdmin gameId={game.id} onLoading={setIsLoading} />
|
||||
) : null}
|
||||
{currentTab === 'details' ? (
|
||||
publicGameError ? (
|
||||
<PlaceholderError onRetry={loadPublicGame}>
|
||||
@@ -437,9 +548,10 @@ export const GameDetailsDialog = ({
|
||||
|
||||
if (!answer) return;
|
||||
|
||||
unregisterGame();
|
||||
unregisterGame(i18n);
|
||||
}}
|
||||
label={<Trans>Unregister this game</Trans>}
|
||||
disabled={isGameUpdating}
|
||||
/>
|
||||
<Spacer />
|
||||
{publicGame.publicWebBuildId && (
|
||||
@@ -447,14 +559,15 @@ export const GameDetailsDialog = ({
|
||||
<RaisedButton
|
||||
onClick={() => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
'Are you sure you want to unpublish this game? \n\nThis will make your Liluo unique game URL not accessible anymore. \n\nYou can decide anytime to publish it again.'
|
||||
'Are you sure you want to unpublish this game? \n\nThis will make your Liluo.io unique game URL not accessible anymore. \n\nYou can decide at any time to publish it again.'
|
||||
);
|
||||
|
||||
if (!answer) return;
|
||||
|
||||
unpublishGame();
|
||||
}}
|
||||
label={<Trans>Unpublish from Liluo</Trans>}
|
||||
label={<Trans>Unpublish from Liluo.io</Trans>}
|
||||
disabled={isGameUpdating}
|
||||
/>
|
||||
<Spacer />
|
||||
</>
|
||||
@@ -463,9 +576,14 @@ export const GameDetailsDialog = ({
|
||||
primary
|
||||
onClick={() => setIsPublicGamePropertiesDialogOpen(true)}
|
||||
label={<Trans>Edit game details</Trans>}
|
||||
disabled={!isGameOpenedAsProject}
|
||||
disabled={!isGameOpenedAsProject || isGameUpdating}
|
||||
/>
|
||||
</Line>
|
||||
{gameUnregisterErrorText ? (
|
||||
<PlaceholderError kind="error">
|
||||
{gameUnregisterErrorText}
|
||||
</PlaceholderError>
|
||||
) : null}
|
||||
</ColumnStackLayout>
|
||||
)
|
||||
) : null}
|
||||
@@ -644,16 +762,21 @@ export const GameDetailsDialog = ({
|
||||
)
|
||||
) : null}
|
||||
</Line>
|
||||
{publicGame && project && (
|
||||
{publicGame && project && isPublicGamePropertiesDialogOpen && (
|
||||
<PublicGamePropertiesDialog
|
||||
open={isPublicGamePropertiesDialogOpen}
|
||||
project={project}
|
||||
publicGame={publicGame}
|
||||
onApply={partialGameChange => {
|
||||
setIsPublicGamePropertiesDialogOpen(false);
|
||||
updateGameFromProject(partialGameChange, i18n);
|
||||
onApply={async partialGameChange => {
|
||||
const isGameUpdated = await updateGameFromProject(
|
||||
partialGameChange,
|
||||
i18n
|
||||
);
|
||||
if (isGameUpdated) {
|
||||
setIsPublicGamePropertiesDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsPublicGamePropertiesDialogOpen(false)}
|
||||
isLoading={isGameUpdating}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
|
48
newIDE/app/src/GameDashboard/GameThumbnail.js
Normal file
48
newIDE/app/src/GameDashboard/GameThumbnail.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Paper } from '@material-ui/core';
|
||||
import * as React from 'react';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
|
||||
const styles = {
|
||||
image: {
|
||||
display: 'block',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
thumbnail: {
|
||||
// 16/9 format
|
||||
width: 272,
|
||||
height: 153,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
thumbnailUrl?: string,
|
||||
gameName: string,
|
||||
|};
|
||||
|
||||
export const GameThumbnail = ({ thumbnailUrl, gameName }: Props) =>
|
||||
thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
style={{
|
||||
...styles.image,
|
||||
...styles.thumbnail,
|
||||
}}
|
||||
alt={gameName}
|
||||
title={gameName}
|
||||
/>
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
style={{
|
||||
...styles.thumbnail,
|
||||
whiteSpace: 'normal',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<EmptyMessage>
|
||||
<Trans>No thumbnail set</Trans>
|
||||
</EmptyMessage>
|
||||
</Paper>
|
||||
);
|
@@ -95,18 +95,10 @@ export const GamesList = (props: Props) => {
|
||||
key={game.id}
|
||||
isCurrentGame={!!projectUuid && game.id === projectUuid}
|
||||
game={game}
|
||||
onOpenDetails={() => {
|
||||
onOpenGameManager={() => {
|
||||
setOpenedGameInitialTab('details');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
onOpenBuilds={() => {
|
||||
setOpenedGameInitialTab('builds');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
onOpenAnalytics={() => {
|
||||
setOpenedGameInitialTab('analytics');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{openedGame && (
|
||||
|
@@ -0,0 +1,161 @@
|
||||
// @flow
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import * as React from 'react';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import DeleteOutline from '@material-ui/icons/DeleteOutline';
|
||||
import Error from '@material-ui/icons/Error';
|
||||
import FirstPage from '@material-ui/icons/FirstPage';
|
||||
import NavigateBefore from '@material-ui/icons/NavigateBefore';
|
||||
import NavigateNext from '@material-ui/icons/NavigateNext';
|
||||
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import { Column, Line } from '../../UI/Grid';
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
import Text from '../../UI/Text';
|
||||
import { textEllipsisStyle } from '../../UI/TextEllipsis';
|
||||
import { type LeaderboardDisplayData } from '../../Utils/GDevelopServices/Play';
|
||||
|
||||
type Props = {|
|
||||
entries: ?Array<LeaderboardDisplayData>,
|
||||
onDeleteEntry: (entryId: string) => Promise<void>,
|
||||
isLoading: boolean,
|
||||
erroredEntry?: {| entryId: string, message: React.Node |},
|
||||
navigation: {|
|
||||
goToFirstPage: ?() => Promise<void>,
|
||||
goToPreviousPage: ?() => Promise<void>,
|
||||
goToNextPage: ?() => Promise<void>,
|
||||
|},
|
||||
|};
|
||||
|
||||
const LeaderboardEntriesTable = ({
|
||||
entries,
|
||||
onDeleteEntry,
|
||||
isLoading,
|
||||
erroredEntry,
|
||||
navigation,
|
||||
}: Props) => {
|
||||
if (!entries) return <PlaceholderLoader />;
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Column expand justifyContent="space-between">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{ width: '20%' }} align="center">
|
||||
<Trans>Score</Trans>
|
||||
</TableCell>
|
||||
<TableCell style={{ width: '50%' }}>
|
||||
<Trans>Player</Trans>
|
||||
</TableCell>
|
||||
<TableCell style={{ width: '15%' }}>
|
||||
<Trans>Date</Trans>
|
||||
</TableCell>
|
||||
<TableCell style={{ width: '15%' }}>
|
||||
<Trans>Action</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{entries.map(entry => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell align="center">{entry.score}</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
...textEllipsisStyle,
|
||||
maxWidth: 0, // to trigger the text ellipsis when overflowing
|
||||
}}
|
||||
>
|
||||
<Tooltip title={entry.playerName}>
|
||||
<span>{entry.playerName}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={i18n.date(entry.createdAt, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
>
|
||||
<span>{i18n.date(entry.createdAt)}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Line>
|
||||
<Tooltip title={'Remove entry'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry(entry.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<DeleteOutline size={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{erroredEntry && erroredEntry.entryId === entry.id ? (
|
||||
<Tooltip title={erroredEntry.message}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {}} // wrap in icon button to match above icon padding
|
||||
>
|
||||
<Error size={20} color="error" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Line>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{entries.length === 0 ? (
|
||||
<Column expand justifyContent="center" alignItems="center">
|
||||
<Text size="body2">
|
||||
<Trans>No entries</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
) : (
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<IconButton
|
||||
tooltip={t`Go to first page`}
|
||||
disabled={!navigation.goToFirstPage}
|
||||
onClick={() => {
|
||||
if (navigation.goToFirstPage) navigation.goToFirstPage();
|
||||
}}
|
||||
>
|
||||
<FirstPage />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={t`Previous page`}
|
||||
disabled={!navigation.goToPreviousPage}
|
||||
onClick={() => {
|
||||
if (navigation.goToPreviousPage)
|
||||
navigation.goToPreviousPage();
|
||||
}}
|
||||
>
|
||||
<NavigateBefore />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={t`Next page`}
|
||||
disabled={!navigation.goToNextPage}
|
||||
onClick={() => {
|
||||
if (navigation.goToNextPage) navigation.goToNextPage();
|
||||
}}
|
||||
>
|
||||
<NavigateNext />
|
||||
</IconButton>
|
||||
</Line>
|
||||
)}
|
||||
</Column>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardEntriesTable;
|
895
newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js
Normal file
895
newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js
Normal file
@@ -0,0 +1,895 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
|
||||
import Add from '@material-ui/icons/Add';
|
||||
import Save from '@material-ui/icons/Save';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import Label from '@material-ui/icons/Label';
|
||||
import Fingerprint from '@material-ui/icons/Fingerprint';
|
||||
import Update from '@material-ui/icons/Update';
|
||||
import Today from '@material-ui/icons/Today';
|
||||
import Sort from '@material-ui/icons/Sort';
|
||||
import PeopleAlt from '@material-ui/icons/PeopleAlt';
|
||||
import SwapVertical from '@material-ui/icons/SwapVert';
|
||||
import Refresh from '@material-ui/icons/Refresh';
|
||||
import Delete from '@material-ui/icons/Delete';
|
||||
import Visibility from '@material-ui/icons/Visibility';
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff';
|
||||
import Loop from '@material-ui/icons/Loop';
|
||||
|
||||
import Copy from '../../UI/CustomSvgIcons/Copy';
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
import { EmptyPlaceholder } from '../../UI/EmptyPlaceholder';
|
||||
import { Column, Line, Spacer } from '../../UI/Grid';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import PlaceholderError from '../../UI/PlaceholderError';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import TextField from '../../UI/TextField';
|
||||
import SelectField from '../../UI/SelectField';
|
||||
import SelectOption from '../../UI/SelectOption';
|
||||
import { useOnlineStatus } from '../../Utils/OnlineStatus';
|
||||
import {
|
||||
type Leaderboard,
|
||||
type LeaderboardSortOption,
|
||||
type LeaderboardPlayerUnicityDisplayOption,
|
||||
type LeaderboardVisibilityOption,
|
||||
breakUuid,
|
||||
} from '../../Utils/GDevelopServices/Play';
|
||||
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
|
||||
import LeaderboardProvider from '../../Leaderboard/LeaderboardProvider';
|
||||
import Window from '../../Utils/Window';
|
||||
import LeaderboardEntriesTable from './LeaderboardEntriesTable';
|
||||
import { ResponsiveLineStackLayout } from '../../UI/Layout';
|
||||
import { useResponsiveWindowWidth } from '../../UI/Reponsive/ResponsiveWindowMeasurer';
|
||||
import { textEllipsisStyle } from '../../UI/TextEllipsis';
|
||||
import { shouldValidate } from '../../UI/KeyboardShortcuts/InteractionKeys';
|
||||
import Text from '../../UI/Text';
|
||||
import { GameRegistration } from '../GameRegistration';
|
||||
|
||||
type Props = {| onLoading: boolean => void, project?: gdProject |};
|
||||
type ContainerProps = {| ...Props, gameId: string |};
|
||||
|
||||
type ApiError = {|
|
||||
action:
|
||||
| 'entriesFetching'
|
||||
| 'entryDeletion'
|
||||
| 'leaderboardsFetching'
|
||||
| 'leaderboardNameUpdate'
|
||||
| 'leaderboardSortUpdate'
|
||||
| 'leaderboardPlayerUnicityDisplayChoiceUpdate'
|
||||
| 'leaderboardCreation'
|
||||
| 'leaderboardReset'
|
||||
| 'leaderboardDeletion',
|
||||
message: React.Node,
|
||||
itemId?: string,
|
||||
|};
|
||||
|
||||
const CenteredError = ({ children }: {| children: React.Node |}) => (
|
||||
<Column expand justifyContent="center" alignItems="center">
|
||||
<Line>{children}</Line>
|
||||
</Column>
|
||||
);
|
||||
|
||||
const styles = {
|
||||
leftColumn: { display: 'flex', flexDirection: 'column', flex: 1, padding: 5 },
|
||||
rightColumn: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 2,
|
||||
},
|
||||
leaderboardConfigurationPaper: { padding: 5, margin: 5 },
|
||||
leaderboardNameText: { ...textEllipsisStyle, width: 150 },
|
||||
leaderboardNameTextField: { width: 125, fontSize: 14 },
|
||||
};
|
||||
|
||||
export const LeaderboardAdmin = ({ onLoading, project }: Props) => {
|
||||
const isOnline = useOnlineStatus();
|
||||
const windowWidth = useResponsiveWindowWidth();
|
||||
const [isEditingName, setIsEditingName] = React.useState<boolean>(false);
|
||||
const [isRequestPending, setIsRequestPending] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [newName, setNewName] = React.useState<string>('');
|
||||
const [newNameError, setNewNameError] = React.useState<?string>(null);
|
||||
const newNameTextFieldRef = React.useRef<?TextField>(null);
|
||||
const [apiError, setApiError] = React.useState<?ApiError>(null);
|
||||
const [
|
||||
displayGameRegistration,
|
||||
setDisplayGameRegistration,
|
||||
] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
leaderboards,
|
||||
listLeaderboards,
|
||||
currentLeaderboard,
|
||||
createLeaderboard,
|
||||
selectLeaderboard,
|
||||
updateLeaderboard,
|
||||
resetLeaderboard,
|
||||
deleteLeaderboard,
|
||||
deleteLeaderboardEntry,
|
||||
displayOnlyBestEntry,
|
||||
setDisplayOnlyBestEntry,
|
||||
fetchLeaderboardEntries,
|
||||
browsing: { entries, goToNextPage, goToPreviousPage, goToFirstPage },
|
||||
} = React.useContext(LeaderboardContext);
|
||||
|
||||
const setIsLoading = React.useCallback(
|
||||
(yesOrNo: boolean) => {
|
||||
setIsRequestPending(yesOrNo);
|
||||
onLoading(yesOrNo);
|
||||
},
|
||||
[onLoading]
|
||||
);
|
||||
|
||||
const onUpdateLeaderboard = async (
|
||||
i18n: I18nType,
|
||||
payload: {|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}
|
||||
) => {
|
||||
setNewNameError(null);
|
||||
if (payload.name !== undefined && payload.name.length === 0) {
|
||||
setNewNameError(
|
||||
i18n._(
|
||||
t`Please enter a name that is at least one character long and 50 at most.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await updateLeaderboard(payload);
|
||||
if (payload.name) setIsEditingName(false);
|
||||
} catch (err) {
|
||||
console.error('An error occurred when updating leaderboard', err);
|
||||
setApiError({
|
||||
action: payload.name
|
||||
? 'leaderboardNameUpdate'
|
||||
: payload.sort
|
||||
? 'leaderboardSortUpdate'
|
||||
: 'leaderboardPlayerUnicityDisplayChoiceUpdate',
|
||||
message: payload.name ? (
|
||||
<Trans>
|
||||
An error occurred when updating the name of the leaderboard, please
|
||||
close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.sort ? (
|
||||
<Trans>
|
||||
An error occurred when updating the sort direction of the
|
||||
leaderboard, please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.visibility ? (
|
||||
<Trans>
|
||||
An error occurred when updating the visibility of the leaderboard,
|
||||
please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
An error occurred when updating the display choice of the
|
||||
leaderboard, please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onListLeaderboards = React.useCallback(
|
||||
() => {
|
||||
const fetchAndHandleError = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await listLeaderboards();
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
setDisplayGameRegistration(true);
|
||||
return;
|
||||
}
|
||||
console.error('An error occurred when fetching leaderboards', err);
|
||||
setApiError({
|
||||
action: 'leaderboardsFetching',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when fetching the leaderboards, please close
|
||||
the dialog and reopen it.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAndHandleError();
|
||||
},
|
||||
[setIsLoading, listLeaderboards]
|
||||
);
|
||||
|
||||
const onFetchLeaderboardEntries = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await fetchLeaderboardEntries();
|
||||
} catch (err) {
|
||||
console.error('An error occurred when fetching leaderboard entries', err);
|
||||
setApiError({
|
||||
action: 'entriesFetching',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when fetching the entries of the leaderboard,
|
||||
please try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateLeaderboard = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await createLeaderboard({
|
||||
name: 'New leaderboard',
|
||||
sort: 'ASC',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('An error occurred when creating leaderboard', err);
|
||||
setApiError({
|
||||
action: 'leaderboardCreation',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when creating a new leaderboard, please close the
|
||||
dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onResetLeaderboard = async (i18n: I18nType) => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
i18n._(
|
||||
t`All current entries will be deleted, are you sure you want to reset this leaderboard? This can't be undone.`
|
||||
)
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await resetLeaderboard();
|
||||
} catch (err) {
|
||||
console.error('An error occurred when resetting leaderboard', err);
|
||||
setApiError({
|
||||
action: 'leaderboardReset',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when resetting the leaderboard, please close the
|
||||
dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteLeaderboard = async (i18n: I18nType) => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
i18n._(
|
||||
t`Are you sure you want to delete this leaderboard and all of its entries? This can't be undone.`
|
||||
)
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await deleteLeaderboard();
|
||||
} catch (err) {
|
||||
console.error('An error occurred when deleting leaderboard', err);
|
||||
setApiError({
|
||||
action: 'leaderboardDeletion',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when deleting the leaderboard, please close the
|
||||
dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteEntry = async (i18n: I18nType, entryId: string) => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
i18n._(
|
||||
t`Are you sure you want to delete this entry? This can't be undone.`
|
||||
)
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await deleteLeaderboardEntry(entryId);
|
||||
} catch (err) {
|
||||
console.error('An error occurred when deleting entry', err);
|
||||
setApiError({
|
||||
action: 'entryDeletion',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when deleting the entry, please try again.
|
||||
</Trans>
|
||||
),
|
||||
itemId: entryId,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (isEditingName && newNameTextFieldRef.current) {
|
||||
newNameTextFieldRef.current.focus();
|
||||
}
|
||||
},
|
||||
[isEditingName]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (leaderboards === null) {
|
||||
onListLeaderboards();
|
||||
}
|
||||
},
|
||||
[leaderboards, onListLeaderboards]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentLeaderboard) onFetchLeaderboardEntries();
|
||||
// This has to be executed on component mount to refresh entries on each admin opening
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
const onCopy = React.useCallback(
|
||||
() => {
|
||||
if (!currentLeaderboard) return;
|
||||
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
|
||||
navigator.clipboard.writeText(currentLeaderboard.id);
|
||||
},
|
||||
[currentLeaderboard]
|
||||
);
|
||||
if (!isOnline) {
|
||||
return (
|
||||
<CenteredError>
|
||||
<PlaceholderError>
|
||||
<Trans>
|
||||
An internet connection is required to administrate your game's
|
||||
leaderboards.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (!!displayGameRegistration) {
|
||||
return (
|
||||
<CenteredError>
|
||||
<GameRegistration
|
||||
project={project}
|
||||
hideIfRegistered
|
||||
onGameRegistered={() => {
|
||||
setDisplayGameRegistration(false);
|
||||
onListLeaderboards();
|
||||
}}
|
||||
/>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (apiError && apiError.action === 'leaderboardCreation') {
|
||||
return (
|
||||
<CenteredError>
|
||||
<AlertMessage kind="error">{apiError.message}</AlertMessage>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (apiError && apiError.action === 'leaderboardsFetching') {
|
||||
return (
|
||||
<CenteredError>
|
||||
<PlaceholderError onRetry={onListLeaderboards} kind="error">
|
||||
{apiError.message}
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (leaderboards === null) {
|
||||
if (isRequestPending) return <PlaceholderLoader />;
|
||||
|
||||
return (
|
||||
<CenteredError>
|
||||
<PlaceholderError onRetry={onListLeaderboards} kind="error">
|
||||
<Trans>
|
||||
An error occurred when retrieving leaderboards, please try again
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
|
||||
if (!!leaderboards && leaderboards.length === 0)
|
||||
return (
|
||||
<Line noMargin expand justifyContent="center" alignItems="center">
|
||||
<EmptyPlaceholder
|
||||
title={<Trans>Create your game's first leaderboard</Trans>}
|
||||
description={<Trans>Leaderboards help retain your players</Trans>}
|
||||
actionLabel={<Trans>Create a leaderboard</Trans>}
|
||||
onAdd={() => {
|
||||
onCreateLeaderboard();
|
||||
}}
|
||||
isLoading={isRequestPending}
|
||||
/>
|
||||
</Line>
|
||||
);
|
||||
|
||||
const getLeaderboardDescription = (
|
||||
i18n: I18nType,
|
||||
currentLeaderboard: Leaderboard
|
||||
) => [
|
||||
{
|
||||
key: 'name',
|
||||
avatar: <Label />,
|
||||
text: isEditingName ? (
|
||||
<Line alignItems="center" expand noMargin>
|
||||
<TextField
|
||||
ref={newNameTextFieldRef}
|
||||
margin="none"
|
||||
style={styles.leaderboardNameTextField}
|
||||
maxLength={50}
|
||||
value={newName}
|
||||
errorText={newNameError}
|
||||
onChange={(e, text) => setNewName(text)}
|
||||
onKeyPress={event => {
|
||||
if (shouldValidate(event) && !isRequestPending) {
|
||||
onUpdateLeaderboard(i18n, { name: newName });
|
||||
}
|
||||
}}
|
||||
disabled={isRequestPending}
|
||||
/>
|
||||
{!isRequestPending && (
|
||||
<>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
tooltip={t`Cancel`}
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => {
|
||||
setIsEditingName(false);
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Line>
|
||||
) : (
|
||||
<Tooltip title={currentLeaderboard.name}>
|
||||
<Text size="body2" style={styles.leaderboardNameText}>
|
||||
{currentLeaderboard.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardNameUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (isEditingName) {
|
||||
onUpdateLeaderboard(i18n, { name: newName });
|
||||
} else {
|
||||
setNewName(currentLeaderboard.name);
|
||||
setIsEditingName(true);
|
||||
}
|
||||
}}
|
||||
tooltip={isEditingName ? t`Save` : t`Rename`}
|
||||
disabled={isRequestPending}
|
||||
edge="end"
|
||||
>
|
||||
{isEditingName ? (
|
||||
isRequestPending ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Save />
|
||||
)
|
||||
) : (
|
||||
<Edit />
|
||||
)}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'id',
|
||||
avatar: <Fingerprint />,
|
||||
text: (
|
||||
<Tooltip title={currentLeaderboard.id}>
|
||||
<Text size="body2">{breakUuid(currentLeaderboard.id)}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText: null,
|
||||
secondaryAction: (
|
||||
<IconButton onClick={onCopy} tooltip={t`Copy`} edge="end">
|
||||
<Copy />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'startDatetime',
|
||||
avatar: <Today />,
|
||||
text: (
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
t`Date from which entries are taken into account: ${i18n.date(
|
||||
currentLeaderboard.startDatetime,
|
||||
{
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}
|
||||
)}`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
{i18n.date(currentLeaderboard.startDatetime)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardReset' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={() => onResetLeaderboard(i18n)}
|
||||
tooltip={t`Reset leaderboard`}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<Update />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
avatar: <Sort />,
|
||||
text: (
|
||||
<Text size="body2">
|
||||
{currentLeaderboard.sort === 'ASC' ? (
|
||||
<Trans>Lower is better</Trans>
|
||||
) : (
|
||||
<Trans>Higher is better</Trans>
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardSortUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await onUpdateLeaderboard(i18n, {
|
||||
sort: currentLeaderboard.sort === 'ASC' ? 'DESC' : 'ASC',
|
||||
});
|
||||
}}
|
||||
tooltip={t`Change sort direction`}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<SwapVertical />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visibility',
|
||||
avatar:
|
||||
currentLeaderboard.visibility === 'HIDDEN' ? (
|
||||
<VisibilityOff />
|
||||
) : (
|
||||
<Visibility />
|
||||
),
|
||||
text: (
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
currentLeaderboard.visibility === 'HIDDEN'
|
||||
? t`Anyone with the link can see it, but it is not listed in your game's leaderboards.`
|
||||
: t`Anyone can access it.`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
{currentLeaderboard.visibility === 'HIDDEN' ? (
|
||||
<Trans>Not visible</Trans>
|
||||
) : (
|
||||
<Trans>Public</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardVisibilityUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await onUpdateLeaderboard(i18n, {
|
||||
visibility:
|
||||
currentLeaderboard.visibility === 'HIDDEN'
|
||||
? 'PUBLIC'
|
||||
: 'HIDDEN',
|
||||
});
|
||||
}}
|
||||
tooltip={
|
||||
currentLeaderboard.visibility === 'HIDDEN'
|
||||
? t`Make the leaderboard public`
|
||||
: t`Hide the leaderboard`
|
||||
}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<Loop />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'playerUnicityDisplayChoice',
|
||||
avatar: <PeopleAlt />,
|
||||
text: (
|
||||
<SelectField
|
||||
fullWidth
|
||||
margin="none"
|
||||
value={currentLeaderboard.playerUnicityDisplayChoice}
|
||||
onChange={(e, i, value) => {
|
||||
onUpdateLeaderboard(i18n, {
|
||||
// $FlowFixMe
|
||||
playerUnicityDisplayChoice: value,
|
||||
});
|
||||
}}
|
||||
disabled={isRequestPending || isEditingName}
|
||||
inputStyle={{ fontSize: 14 }}
|
||||
helperMarkdownText={
|
||||
currentLeaderboard.playerUnicityDisplayChoice === 'FREE'
|
||||
? i18n._(
|
||||
t`Users can chose to see only players' best entries or not.`
|
||||
)
|
||||
: currentLeaderboard.playerUnicityDisplayChoice ===
|
||||
'PREFER_UNIQUE'
|
||||
? i18n._(t`Only player's best entries are displayed.`)
|
||||
: i18n._(t`All entries are displayed.`)
|
||||
}
|
||||
>
|
||||
<SelectOption
|
||||
key={'free'}
|
||||
value={'FREE'}
|
||||
primaryText={i18n._(t`Let the user select`)}
|
||||
/>
|
||||
<SelectOption
|
||||
key={'prefer-unique'}
|
||||
value={'PREFER_UNIQUE'}
|
||||
primaryText={i18n._(t`Only best entry`)}
|
||||
/>
|
||||
<SelectOption
|
||||
key={'prefer-non-unique'}
|
||||
value={'PREFER_NON_UNIQUE'}
|
||||
primaryText={i18n._(t`All entries`)}
|
||||
/>
|
||||
</SelectField>
|
||||
),
|
||||
secondaryText:
|
||||
apiError &&
|
||||
apiError.action === 'leaderboardPlayerUnicityDisplayChoiceUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ResponsiveLineStackLayout noMargin expand noColumnMargin>
|
||||
<div style={styles.leftColumn}>
|
||||
<Paper elevation={5} style={styles.leaderboardConfigurationPaper}>
|
||||
<Column>
|
||||
<Line>
|
||||
{currentLeaderboard && leaderboards ? (
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>Leaderboard name</Trans>}
|
||||
value={currentLeaderboard.id}
|
||||
onChange={(e, i, leaderboardId) => {
|
||||
selectLeaderboard(leaderboardId);
|
||||
}}
|
||||
>
|
||||
{leaderboards.map(leaderboard => (
|
||||
<SelectOption
|
||||
key={leaderboard.id}
|
||||
value={leaderboard.id}
|
||||
primaryText={leaderboard.name}
|
||||
/>
|
||||
))}
|
||||
</SelectField>
|
||||
) : null}
|
||||
<IconButton
|
||||
onClick={onCreateLeaderboard}
|
||||
disabled={isEditingName || isRequestPending}
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</Line>
|
||||
{currentLeaderboard ? (
|
||||
<>
|
||||
<List>
|
||||
{getLeaderboardDescription(i18n, currentLeaderboard).map(
|
||||
(item, index) => (
|
||||
<React.Fragment key={`fragment-${item.key}`}>
|
||||
{index > 0 ? (
|
||||
<Divider
|
||||
key={`divider-${item.key}`}
|
||||
component="li"
|
||||
/>
|
||||
) : null}
|
||||
<ListItem key={item.key} disableGutters>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{item.avatar}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
secondary={item.secondaryText}
|
||||
>
|
||||
{item.text}
|
||||
</ListItemText>
|
||||
{item.secondaryAction ? (
|
||||
<ListItemSecondaryAction>
|
||||
{item.secondaryAction}
|
||||
</ListItemSecondaryAction>
|
||||
) : null}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</List>
|
||||
<Line>
|
||||
<RaisedButton
|
||||
icon={<Delete />}
|
||||
label={<Trans>Delete</Trans>}
|
||||
disabled={isRequestPending || isEditingName}
|
||||
onClick={() => onDeleteLeaderboard(i18n)}
|
||||
/>
|
||||
</Line>
|
||||
{apiError && apiError.action === 'leaderboardDeletion' ? (
|
||||
<PlaceholderError kind="error">
|
||||
{apiError.message}
|
||||
</PlaceholderError>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Column>
|
||||
</Paper>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...styles.rightColumn,
|
||||
paddingLeft: windowWidth === 'small' ? 0 : 20,
|
||||
}}
|
||||
>
|
||||
<Line alignItems="center" justifyContent="flex-end">
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
t`When checked, will only display the best score of each player (only for the display below).`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
<Trans>Player best entry</Trans>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
color="primary"
|
||||
size="small"
|
||||
checked={displayOnlyBestEntry}
|
||||
onClick={() => setDisplayOnlyBestEntry(!displayOnlyBestEntry)}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<IconButton
|
||||
onClick={onFetchLeaderboardEntries}
|
||||
disabled={isRequestPending || isEditingName}
|
||||
tooltip={t`Refresh`}
|
||||
size="small"
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
<Spacer />
|
||||
</Line>
|
||||
{apiError && apiError.action === 'entriesFetching' ? (
|
||||
<CenteredError>
|
||||
<PlaceholderError
|
||||
onRetry={onFetchLeaderboardEntries}
|
||||
kind="error"
|
||||
>
|
||||
{apiError.message}
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
) : (
|
||||
<LeaderboardEntriesTable
|
||||
entries={entries}
|
||||
onDeleteEntry={entryId => onDeleteEntry(i18n, entryId)}
|
||||
isLoading={isRequestPending || isEditingName}
|
||||
navigation={{
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
goToFirstPage,
|
||||
}}
|
||||
erroredEntry={
|
||||
apiError &&
|
||||
apiError.action === 'entryDeletion' &&
|
||||
apiError.itemId
|
||||
? { entryId: apiError.itemId, message: apiError.message }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveLineStackLayout>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
const LeaderboardAdminContainer = ({
|
||||
gameId,
|
||||
...otherProps
|
||||
}: ContainerProps) => (
|
||||
<LeaderboardProvider gameId={gameId}>
|
||||
<LeaderboardAdmin {...otherProps} />
|
||||
</LeaderboardProvider>
|
||||
);
|
||||
|
||||
export default LeaderboardAdminContainer;
|
325
newIDE/app/src/GameDashboard/PublicGameProperties.js
Normal file
325
newIDE/app/src/GameDashboard/PublicGameProperties.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import SemiControlledTextField from '../UI/SemiControlledTextField';
|
||||
import { UsersAutocomplete } from '../Utils/UsersAutocomplete';
|
||||
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import Checkbox from '../UI/Checkbox';
|
||||
import SelectField from '../UI/SelectField';
|
||||
import SelectOption from '../UI/SelectOption';
|
||||
import { t } from '@lingui/macro';
|
||||
import SemiControlledMultiAutoComplete from '../UI/SemiControlledMultiAutoComplete';
|
||||
import {
|
||||
allGameCategories,
|
||||
getCategoryName,
|
||||
} from '../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
import BackgroundText from '../UI/BackgroundText';
|
||||
import AlertMessage from '../UI/AlertMessage';
|
||||
import { GameThumbnail } from './GameThumbnail';
|
||||
|
||||
const isCyrillic = (text: string) =>
|
||||
/[БГДЖЗИЙЛПФЦЧШЩЫЭЮЯбвгджзийклмнптфцчшщыэюя]/.test(text);
|
||||
const cyrillicToLatinMapping = require('./CyrillicToLatin.json');
|
||||
|
||||
export const cleanUpGameSlug = (gameSlug: string) => {
|
||||
let latinGameSlug = gameSlug;
|
||||
if (isCyrillic(gameSlug)) {
|
||||
latinGameSlug = gameSlug
|
||||
.split('')
|
||||
.map(function(char) {
|
||||
const latin = cyrillicToLatinMapping[char];
|
||||
return latin === undefined ? char : latin;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
return latinGameSlug
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '-')
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
// Properties visible in the project properties and game dialogs.
|
||||
setName: string => void,
|
||||
name: string,
|
||||
setDescription: string => void,
|
||||
description: ?string,
|
||||
setAuthorIds: (string[]) => void,
|
||||
authorIds: string[],
|
||||
setOrientation: string => void,
|
||||
orientation: string,
|
||||
// Properties only visible in the game dialog.
|
||||
setCategories?: (string[]) => void,
|
||||
categories?: string[],
|
||||
setOwnerIds?: (string[]) => void,
|
||||
ownerIds?: string[],
|
||||
setPlayableWithKeyboard?: boolean => void,
|
||||
playWithKeyboard?: boolean,
|
||||
setPlayableWithGamepad?: boolean => void,
|
||||
playWithGamepad?: boolean,
|
||||
setPlayableWithMobile?: boolean => void,
|
||||
playWithMobile?: boolean,
|
||||
userSlug?: string,
|
||||
setUserSlug?: string => void,
|
||||
gameSlug?: string,
|
||||
setGameSlug?: string => void,
|
||||
setDiscoverable?: boolean => void,
|
||||
discoverable?: boolean,
|
||||
displayThumbnail?: boolean,
|
||||
thumbnailUrl?: string,
|
||||
|};
|
||||
|
||||
export function PublicGameProperties({
|
||||
project,
|
||||
setName,
|
||||
name,
|
||||
categories,
|
||||
setCategories,
|
||||
setDescription,
|
||||
description,
|
||||
setAuthorIds,
|
||||
authorIds,
|
||||
setOwnerIds,
|
||||
ownerIds,
|
||||
setPlayableWithKeyboard,
|
||||
playWithKeyboard,
|
||||
setPlayableWithGamepad,
|
||||
playWithGamepad,
|
||||
setPlayableWithMobile,
|
||||
playWithMobile,
|
||||
setOrientation,
|
||||
orientation,
|
||||
userSlug,
|
||||
setUserSlug,
|
||||
gameSlug,
|
||||
setGameSlug,
|
||||
setDiscoverable,
|
||||
discoverable,
|
||||
displayThumbnail,
|
||||
thumbnailUrl,
|
||||
}: Props) {
|
||||
const [categoryInput, setCategoryInput] = React.useState('');
|
||||
const { profile } = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ColumnStackLayout noMargin>
|
||||
<ResponsiveLineStackLayout noMargin>
|
||||
{displayThumbnail && (
|
||||
<>
|
||||
<Column noMargin>
|
||||
<GameThumbnail
|
||||
gameName={project.getName()}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
/>
|
||||
</Column>
|
||||
<Spacer />
|
||||
</>
|
||||
)}
|
||||
<ColumnStackLayout noMargin expand>
|
||||
<SemiControlledTextField
|
||||
floatingLabelText={<Trans>Game name</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
autoFocus
|
||||
/>
|
||||
{setCategories && (
|
||||
<SemiControlledMultiAutoComplete
|
||||
hintText={t`Select a genre`}
|
||||
floatingLabelText={<Trans>Genres</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select up to 4 genres for the game to be visible on
|
||||
Liluo.io's categories pages!
|
||||
</Trans>
|
||||
}
|
||||
value={
|
||||
categories
|
||||
? categories.map(category => ({
|
||||
value: category,
|
||||
text: getCategoryName(category, i18n),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onChange={(event, values) => {
|
||||
setCategories(
|
||||
values ? values.map(category => category.value) : []
|
||||
);
|
||||
}}
|
||||
inputValue={categoryInput}
|
||||
onInputChange={(event, value) => {
|
||||
setCategoryInput(value);
|
||||
}}
|
||||
dataSource={allGameCategories.map(category => ({
|
||||
value: category,
|
||||
text: getCategoryName(category, i18n),
|
||||
}))}
|
||||
fullWidth
|
||||
optionsLimit={4}
|
||||
/>
|
||||
)}
|
||||
{setDiscoverable && (
|
||||
<Checkbox
|
||||
label={<Trans>Make your game discoverable on Liluo.io</Trans>}
|
||||
checked={!!discoverable}
|
||||
onCheck={(e, checked) => setDiscoverable(checked)}
|
||||
/>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</ResponsiveLineStackLayout>
|
||||
{displayThumbnail && (
|
||||
<Line noMargin>
|
||||
<BackgroundText>
|
||||
<Trans>
|
||||
To update your thumbnail, go into your Game Settings > Icons
|
||||
and thumbnail, then create and publish a new build.
|
||||
</Trans>
|
||||
</BackgroundText>
|
||||
</Line>
|
||||
)}
|
||||
<SemiControlledTextField
|
||||
floatingLabelText={<Trans>Game description</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={description || ''}
|
||||
onChange={setDescription}
|
||||
autoFocus
|
||||
multiline
|
||||
rows={5}
|
||||
/>
|
||||
{setUserSlug && setGameSlug && (
|
||||
<>
|
||||
<Line>
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>User name in the game URL</Trans>}
|
||||
value={userSlug || ''}
|
||||
onChange={(e, i, value: string) => setUserSlug(value)}
|
||||
// It's disabled if one of the condition of SelectOption is false.
|
||||
disabled={
|
||||
!(
|
||||
profile &&
|
||||
profile.username &&
|
||||
userSlug &&
|
||||
(!profile || userSlug !== profile.username)
|
||||
)
|
||||
}
|
||||
>
|
||||
{profile && profile.username && (
|
||||
<SelectOption
|
||||
value={profile.username}
|
||||
primaryText={profile.username}
|
||||
/>
|
||||
)}
|
||||
{userSlug && (!profile || userSlug !== profile.username) && (
|
||||
<SelectOption value={userSlug} primaryText={userSlug} />
|
||||
)}
|
||||
</SelectField>
|
||||
<Spacer />
|
||||
<SemiControlledTextField
|
||||
disabled={
|
||||
!(
|
||||
userSlug &&
|
||||
userSlug.length &&
|
||||
profile &&
|
||||
profile.username
|
||||
)
|
||||
}
|
||||
floatingLabelText={<Trans>Game name in the game URL</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={
|
||||
userSlug && userSlug.length && profile && profile.username
|
||||
? gameSlug || ''
|
||||
: ''
|
||||
}
|
||||
onChange={gameSlug => setGameSlug(cleanUpGameSlug(gameSlug))}
|
||||
autoFocus
|
||||
/>
|
||||
</Line>
|
||||
{!(
|
||||
userSlug &&
|
||||
userSlug.length &&
|
||||
profile &&
|
||||
profile.username
|
||||
) && (
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
Usernames are required to choose a custom game URL.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<UsersAutocomplete
|
||||
userIds={authorIds}
|
||||
onChange={setAuthorIds}
|
||||
floatingLabelText={<Trans>Authors</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select the usernames of the authors of this project. They will
|
||||
be displayed in the selected order, if you publish this game as
|
||||
an example or in the community.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
{setOwnerIds && (
|
||||
<UsersAutocomplete
|
||||
userIds={ownerIds || []}
|
||||
onChange={setOwnerIds}
|
||||
floatingLabelText={<Trans>Owners</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select the usernames of the owners of this project to let them
|
||||
manage this game builds. Be aware that owners can revoke your
|
||||
ownership.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>Device orientation (for mobile)</Trans>}
|
||||
value={orientation}
|
||||
onChange={(e, i, value: string) => setOrientation(value)}
|
||||
>
|
||||
<SelectOption value="default" primaryText={t`Platform default`} />
|
||||
<SelectOption value="landscape" primaryText={t`Landscape`} />
|
||||
<SelectOption value="portrait" primaryText={t`Portrait`} />
|
||||
</SelectField>
|
||||
{setPlayableWithKeyboard &&
|
||||
setPlayableWithGamepad &&
|
||||
setPlayableWithMobile && (
|
||||
<React.Fragment>
|
||||
<Checkbox
|
||||
label={<Trans>Playable with a keyboard</Trans>}
|
||||
checked={!!playWithKeyboard}
|
||||
onCheck={(e, checked) => setPlayableWithKeyboard(checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Playable with a gamepad</Trans>}
|
||||
checked={!!playWithGamepad}
|
||||
onCheck={(e, checked) => setPlayableWithGamepad(checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Playable on mobile</Trans>}
|
||||
checked={!!playWithMobile}
|
||||
onCheck={(e, checked) => setPlayableWithMobile(checked)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicGameProperties;
|
@@ -2,7 +2,7 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import React from 'react';
|
||||
import PublicGameProperties from './PublicGameProperties';
|
||||
import { PublicGameProperties, cleanUpGameSlug } from './PublicGameProperties';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import {
|
||||
displayProjectErrorsBox,
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import Dialog from '../UI/Dialog';
|
||||
import { type PublicGame } from '../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
|
||||
/**
|
||||
* Changes that are not stored in the Project.
|
||||
*/
|
||||
export type PartialGameChange = {|
|
||||
ownerIds: Array<string>,
|
||||
ownerIds?: Array<string>,
|
||||
userSlug?: string,
|
||||
gameSlug?: string,
|
||||
discoverable?: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
@@ -33,10 +37,10 @@ type PublicProjectProperties = {|
|
||||
orientation: string,
|
||||
|};
|
||||
|
||||
function applyPublicPropertiesToProject(
|
||||
export const applyPublicPropertiesToProject = (
|
||||
project: gdProject,
|
||||
newProperties: PublicProjectProperties
|
||||
) {
|
||||
) => {
|
||||
const t = str => str; //TODO
|
||||
const { name, authorIds, description, categories } = newProperties;
|
||||
project.setName(name);
|
||||
@@ -53,23 +57,25 @@ function applyPublicPropertiesToProject(
|
||||
project.setOrientation(newProperties.orientation);
|
||||
|
||||
return displayProjectErrorsBox(t, getProjectPropertiesErrors(t, project));
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
publicGame: PublicGame,
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onApply: (partialGameChange: PartialGameChange) => void,
|
||||
onApply: (partialGameChange: PartialGameChange) => Promise<void>,
|
||||
isLoading: boolean,
|
||||
|};
|
||||
|
||||
export const PublicGamePropertiesDialog = ({
|
||||
project,
|
||||
publicGame,
|
||||
open,
|
||||
onClose,
|
||||
onApply,
|
||||
isLoading,
|
||||
}: Props) => {
|
||||
const { profile } = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const publicGameAuthorIds = publicGame.authors.map(author => author.id);
|
||||
const publicGameOwnerIds = publicGame.owners.map(owner => owner.id);
|
||||
const [name, setName] = React.useState(publicGame.gameName);
|
||||
@@ -89,10 +95,17 @@ export const PublicGamePropertiesDialog = ({
|
||||
publicGame.playWithMobile
|
||||
);
|
||||
const [orientation, setOrientation] = React.useState(publicGame.orientation);
|
||||
const [userSlug, setUserSlug] = React.useState(
|
||||
publicGame.userSlug || (profile && profile.username) || ''
|
||||
);
|
||||
const [gameSlug, setGameSlug] = React.useState(
|
||||
publicGame.gameSlug || cleanUpGameSlug(publicGame.gameName)
|
||||
);
|
||||
const [discoverable, setDiscoverable] = React.useState(
|
||||
publicGame.discoverable
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const onSave = () => {
|
||||
const onSave = async () => {
|
||||
if (
|
||||
applyPublicPropertiesToProject(project, {
|
||||
name,
|
||||
@@ -105,7 +118,7 @@ export const PublicGamePropertiesDialog = ({
|
||||
orientation: orientation || 'default',
|
||||
})
|
||||
) {
|
||||
onApply({ ownerIds });
|
||||
await onApply({ ownerIds, userSlug, gameSlug, discoverable });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,12 +128,14 @@ export const PublicGamePropertiesDialog = ({
|
||||
key="back"
|
||||
primary={false}
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
<RaisedButton
|
||||
label={<Trans>Save</Trans>}
|
||||
primary
|
||||
onClick={onSave}
|
||||
key="save"
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
];
|
||||
|
||||
@@ -130,7 +145,7 @@ export const PublicGamePropertiesDialog = ({
|
||||
onRequestClose={onClose}
|
||||
actions={actions}
|
||||
cannotBeDismissed={false}
|
||||
open={open}
|
||||
open
|
||||
>
|
||||
<PublicGameProperties
|
||||
name={name}
|
||||
@@ -152,6 +167,14 @@ export const PublicGamePropertiesDialog = ({
|
||||
playWithMobile={playWithMobile}
|
||||
setOrientation={setOrientation}
|
||||
orientation={orientation}
|
||||
setUserSlug={setUserSlug}
|
||||
userSlug={userSlug}
|
||||
setGameSlug={setGameSlug}
|
||||
gameSlug={gameSlug}
|
||||
setDiscoverable={setDiscoverable}
|
||||
discoverable={discoverable}
|
||||
displayThumbnail
|
||||
thumbnailUrl={publicGame.thumbnailUrl}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
@@ -83,6 +83,12 @@ const jsExtensions = [
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Firebase/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'Leaderboards',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Leaderboards/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'DialogueTree',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
@@ -147,6 +153,12 @@ const jsExtensions = [
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Screenshot/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'TextInput',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/TextInput/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
];
|
||||
|
||||
type MakeExtensionsLoaderArguments = {|
|
||||
|
65
newIDE/app/src/Leaderboard/LeaderboardContext.js
Normal file
65
newIDE/app/src/Leaderboard/LeaderboardContext.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type Leaderboard,
|
||||
type LeaderboardSortOption,
|
||||
type LeaderboardDisplayData,
|
||||
type LeaderboardPlayerUnicityDisplayOption,
|
||||
type LeaderboardVisibilityOption,
|
||||
} from '../Utils/GDevelopServices/Play';
|
||||
|
||||
export type LeaderboardState = {|
|
||||
leaderboards: ?Array<Leaderboard>,
|
||||
currentLeaderboard: ?Leaderboard,
|
||||
displayOnlyBestEntry: boolean,
|
||||
browsing: {|
|
||||
entries: ?Array<LeaderboardDisplayData>,
|
||||
goToNextPage: ?() => Promise<void>,
|
||||
goToPreviousPage: ?() => Promise<void>,
|
||||
goToFirstPage: ?() => Promise<void>,
|
||||
|},
|
||||
createLeaderboard: ({|
|
||||
name: string,
|
||||
sort: LeaderboardSortOption,
|
||||
|}) => Promise<?Leaderboard>,
|
||||
listLeaderboards: () => Promise<void>,
|
||||
selectLeaderboard: (id: string) => void,
|
||||
setDisplayOnlyBestEntry: boolean => void,
|
||||
updateLeaderboard: ({|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}) => Promise<void>,
|
||||
resetLeaderboard: () => Promise<void>,
|
||||
deleteLeaderboard: () => Promise<void>,
|
||||
deleteLeaderboardEntry: (entryId: string) => Promise<void>,
|
||||
fetchLeaderboardEntries: () => Promise<void>,
|
||||
|};
|
||||
|
||||
export const initialLeaderboardState = {
|
||||
leaderboards: null,
|
||||
currentLeaderboard: null,
|
||||
displayOnlyBestEntry: false,
|
||||
browsing: {
|
||||
entries: null,
|
||||
goToNextPage: null,
|
||||
goToPreviousPage: null,
|
||||
goToFirstPage: null,
|
||||
},
|
||||
createLeaderboard: async () => null,
|
||||
listLeaderboards: async () => {},
|
||||
selectLeaderboard: () => {},
|
||||
setDisplayOnlyBestEntry: () => {},
|
||||
updateLeaderboard: async () => {},
|
||||
resetLeaderboard: async () => {},
|
||||
deleteLeaderboard: async () => {},
|
||||
deleteLeaderboardEntry: async entryId => {},
|
||||
fetchLeaderboardEntries: async () => {},
|
||||
};
|
||||
|
||||
const LeaderboardContext = React.createContext<LeaderboardState>(
|
||||
initialLeaderboardState
|
||||
);
|
||||
|
||||
export default LeaderboardContext;
|
48
newIDE/app/src/Leaderboard/LeaderboardDialog.js
Normal file
48
newIDE/app/src/Leaderboard/LeaderboardDialog.js
Normal file
@@ -0,0 +1,48 @@
|
||||
//@flow
|
||||
import React from 'react';
|
||||
import { LeaderboardAdmin } from '../GameDashboard/LeaderboardAdmin';
|
||||
|
||||
import Dialog from '../UI/Dialog';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import HelpButton from '../UI/HelpButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
type Props = {|
|
||||
onClose: () => void,
|
||||
open: boolean,
|
||||
project: gdProject,
|
||||
|};
|
||||
|
||||
const LeaderboardDialog = ({ onClose, open, project }: Props) => {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
return (
|
||||
<Dialog
|
||||
actions={[
|
||||
<FlatButton
|
||||
label={<Trans>Close</Trans>}
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
key={'Close'}
|
||||
/>,
|
||||
]}
|
||||
secondaryActions={[
|
||||
<HelpButton
|
||||
key="help"
|
||||
helpPagePath="/interface/games-dashboard/leaderboard-administration"
|
||||
/>,
|
||||
]}
|
||||
open={open}
|
||||
cannotBeDismissed={true}
|
||||
onRequestClose={() => {
|
||||
if (!isLoading) onClose();
|
||||
}}
|
||||
title={<Trans>Leaderboards</Trans>}
|
||||
flexBody
|
||||
fullHeight
|
||||
>
|
||||
<LeaderboardAdmin onLoading={setIsLoading} project={project} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardDialog;
|
430
newIDE/app/src/Leaderboard/LeaderboardProvider.js
Normal file
430
newIDE/app/src/Leaderboard/LeaderboardProvider.js
Normal file
@@ -0,0 +1,430 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import LeaderboardContext from './LeaderboardContext';
|
||||
import {
|
||||
type Leaderboard,
|
||||
type LeaderboardEntry,
|
||||
type LeaderboardExtremePlayerScore,
|
||||
type LeaderboardSortOption,
|
||||
type LeaderboardVisibilityOption,
|
||||
type LeaderboardPlayerUnicityDisplayOption,
|
||||
type LeaderboardDisplayData,
|
||||
createLeaderboard as doCreateLeaderboard,
|
||||
updateLeaderboard as doUpdateLeaderboard,
|
||||
resetLeaderboard as doResetLeaderboard,
|
||||
deleteLeaderboardEntry as doDeleteLeaderboardEntry,
|
||||
deleteLeaderboard as doDeleteLeaderboard,
|
||||
extractExtremeScoreDisplayData,
|
||||
extractEntryDisplayData,
|
||||
listLeaderboardEntries,
|
||||
listGameLeaderboards,
|
||||
} from '../Utils/GDevelopServices/Play';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
|
||||
type Props = {| gameId: string, children: React.Node |};
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const shouldDisplayOnlyBestEntries = (leaderboard: Leaderboard) =>
|
||||
leaderboard.playerUnicityDisplayChoice === 'PREFER_UNIQUE';
|
||||
|
||||
type ReducerState = {|
|
||||
currentLeaderboardId: ?string,
|
||||
currentLeaderboard: ?Leaderboard,
|
||||
leaderboardsByIds: ?{| [string]: Leaderboard |},
|
||||
displayOnlyBestEntry: boolean,
|
||||
entries: ?Array<LeaderboardDisplayData>,
|
||||
mapPageIndexToUri: {| [number]: string |},
|
||||
pageIndex: number,
|
||||
|};
|
||||
|
||||
type ReducerAction =
|
||||
| {| type: 'SET_LEADERBOARDS', payload: ?Array<Leaderboard> |}
|
||||
| {| type: 'SET_ENTRIES', payload: ?Array<LeaderboardDisplayData> |}
|
||||
| {| type: 'SET_NEXT_PAGE_URI', payload: string |}
|
||||
| {| type: 'SELECT_LEADERBOARD', payload: string |}
|
||||
| {| type: 'SET_PAGE_INDEX', payload: number |}
|
||||
| {| type: 'PURGE_NAVIGATION' |}
|
||||
| {| type: 'CHANGE_DISPLAY_ONLY_BEST_ENTRY', payload: boolean |}
|
||||
| {| type: 'UPDATE_OR_CREATE_LEADERBOARD', payload: Leaderboard |}
|
||||
| {| type: 'REMOVE_LEADERBOARD', payload: string |};
|
||||
|
||||
const reducer = (state: ReducerState, action: ReducerAction): ReducerState => {
|
||||
switch (action.type) {
|
||||
case 'SET_LEADERBOARDS':
|
||||
const leaderboards = action.payload;
|
||||
if (!leaderboards)
|
||||
return {
|
||||
...state,
|
||||
leaderboardsByIds: null,
|
||||
currentLeaderboardId: null,
|
||||
currentLeaderboard: null,
|
||||
};
|
||||
|
||||
const leaderboardsByIds = leaderboards.reduce((acc, leaderboard) => {
|
||||
acc[leaderboard.id] = leaderboard;
|
||||
return acc;
|
||||
}, {});
|
||||
const shouldDefineCurrentLeaderboardIfNoneSelected =
|
||||
!state.currentLeaderboard && leaderboards && leaderboards.length > 0;
|
||||
const newCurrentLeaderboard = shouldDefineCurrentLeaderboardIfNoneSelected
|
||||
? leaderboards[0]
|
||||
: state.currentLeaderboard;
|
||||
return {
|
||||
...state,
|
||||
leaderboardsByIds,
|
||||
displayOnlyBestEntry: newCurrentLeaderboard
|
||||
? shouldDisplayOnlyBestEntries(newCurrentLeaderboard)
|
||||
: false,
|
||||
currentLeaderboardId: newCurrentLeaderboard
|
||||
? newCurrentLeaderboard.id
|
||||
: null,
|
||||
currentLeaderboard: newCurrentLeaderboard,
|
||||
};
|
||||
case 'SET_ENTRIES':
|
||||
return {
|
||||
...state,
|
||||
entries: action.payload,
|
||||
};
|
||||
case 'PURGE_NAVIGATION':
|
||||
return {
|
||||
...state,
|
||||
entries: null,
|
||||
pageIndex: 0,
|
||||
mapPageIndexToUri: {},
|
||||
};
|
||||
case 'SET_NEXT_PAGE_URI':
|
||||
const nextPageIndex = state.pageIndex + 1;
|
||||
return {
|
||||
...state,
|
||||
mapPageIndexToUri: {
|
||||
...state.mapPageIndexToUri,
|
||||
[nextPageIndex]: action.payload,
|
||||
},
|
||||
};
|
||||
case 'SET_PAGE_INDEX':
|
||||
return {
|
||||
...state,
|
||||
pageIndex: action.payload,
|
||||
};
|
||||
case 'SELECT_LEADERBOARD':
|
||||
if (!state.leaderboardsByIds) return state;
|
||||
const leaderboard = state.leaderboardsByIds[action.payload];
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(leaderboard),
|
||||
currentLeaderboardId: leaderboard.id,
|
||||
currentLeaderboard: leaderboard,
|
||||
};
|
||||
case 'CHANGE_DISPLAY_ONLY_BEST_ENTRY':
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: action.payload,
|
||||
};
|
||||
case 'UPDATE_OR_CREATE_LEADERBOARD':
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(action.payload),
|
||||
leaderboardsByIds: {
|
||||
...state.leaderboardsByIds,
|
||||
[action.payload.id]: action.payload,
|
||||
},
|
||||
currentLeaderboardId: action.payload.id,
|
||||
currentLeaderboard: action.payload,
|
||||
};
|
||||
case 'REMOVE_LEADERBOARD':
|
||||
const newLeaderboardsByIds = { ...state.leaderboardsByIds };
|
||||
delete newLeaderboardsByIds[action.payload];
|
||||
const leaderboardsIds = Object.keys(newLeaderboardsByIds);
|
||||
if (leaderboardsIds.length === 0) {
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: false,
|
||||
leaderboardsByIds: newLeaderboardsByIds,
|
||||
currentLeaderboard: null,
|
||||
currentLeaderboardId: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(
|
||||
newLeaderboardsByIds[leaderboardsIds[0]]
|
||||
),
|
||||
leaderboardsByIds: newLeaderboardsByIds,
|
||||
currentLeaderboard: newLeaderboardsByIds[leaderboardsIds[0]],
|
||||
currentLeaderboardId: leaderboardsIds[0],
|
||||
};
|
||||
default: {
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const LeaderboardProvider = ({ gameId, children }: Props) => {
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
// Ensure that only one request for leaderboards list is sent at the same time.
|
||||
const isListingLeaderboards = React.useRef(false);
|
||||
|
||||
const [
|
||||
{
|
||||
currentLeaderboardId,
|
||||
currentLeaderboard,
|
||||
leaderboardsByIds,
|
||||
displayOnlyBestEntry,
|
||||
entries,
|
||||
mapPageIndexToUri,
|
||||
pageIndex,
|
||||
},
|
||||
dispatch,
|
||||
] = React.useReducer<ReducerState, ReducerAction>(reducer, {
|
||||
currentLeaderboardId: null,
|
||||
currentLeaderboard: null,
|
||||
leaderboardsByIds: null,
|
||||
displayOnlyBestEntry: false,
|
||||
entries: null,
|
||||
mapPageIndexToUri: {},
|
||||
pageIndex: 0,
|
||||
});
|
||||
|
||||
const listLeaderboards = React.useCallback(
|
||||
async () => {
|
||||
if (!isListingLeaderboards.current) {
|
||||
isListingLeaderboards.current = true;
|
||||
try {
|
||||
dispatch({ type: 'SET_LEADERBOARDS', payload: null });
|
||||
const fetchedLeaderboards = await listGameLeaderboards(
|
||||
authenticatedUser,
|
||||
gameId
|
||||
);
|
||||
if (!fetchedLeaderboards) return;
|
||||
fetchedLeaderboards.sort((a, b) => a.name.localeCompare(b.name));
|
||||
dispatch({
|
||||
type: 'SET_LEADERBOARDS',
|
||||
payload: fetchedLeaderboards,
|
||||
});
|
||||
} finally {
|
||||
isListingLeaderboards.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[gameId, authenticatedUser]
|
||||
);
|
||||
|
||||
const createLeaderboard = React.useCallback(
|
||||
async (creationPayload: {|
|
||||
name: string,
|
||||
sort: LeaderboardSortOption,
|
||||
|}) => {
|
||||
dispatch({ type: 'SET_ENTRIES', payload: null });
|
||||
const newLeaderboard = await doCreateLeaderboard(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
creationPayload
|
||||
);
|
||||
if (!newLeaderboard) return;
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_OR_CREATE_LEADERBOARD',
|
||||
payload: newLeaderboard,
|
||||
});
|
||||
},
|
||||
[gameId, authenticatedUser]
|
||||
);
|
||||
|
||||
const fetchEntries = React.useCallback(
|
||||
async (options?: {| uri?: ?string |}) => {
|
||||
if (!currentLeaderboardId) return;
|
||||
|
||||
const uriToUse = options && options.uri ? options.uri : null;
|
||||
|
||||
dispatch({ type: 'SET_ENTRIES', payload: null });
|
||||
const data = await listLeaderboardEntries(gameId, currentLeaderboardId, {
|
||||
pageSize,
|
||||
onlyBestEntry: displayOnlyBestEntry,
|
||||
forceUri: uriToUse,
|
||||
});
|
||||
if (!data) return;
|
||||
const fetchedEntries:
|
||||
| LeaderboardEntry[]
|
||||
| LeaderboardExtremePlayerScore[] = data.entries;
|
||||
|
||||
if (data.nextPageUri) {
|
||||
dispatch({ type: 'SET_NEXT_PAGE_URI', payload: data.nextPageUri });
|
||||
}
|
||||
|
||||
let entriesToDisplay: LeaderboardDisplayData[] = [];
|
||||
if (displayOnlyBestEntry) {
|
||||
entriesToDisplay = fetchedEntries.map(entry =>
|
||||
// $FlowFixMe
|
||||
extractExtremeScoreDisplayData(entry)
|
||||
);
|
||||
} else {
|
||||
entriesToDisplay = fetchedEntries.map(entry =>
|
||||
// $FlowFixMe
|
||||
extractEntryDisplayData(entry)
|
||||
);
|
||||
}
|
||||
dispatch({ type: 'SET_ENTRIES', payload: entriesToDisplay });
|
||||
},
|
||||
[currentLeaderboardId, displayOnlyBestEntry, gameId]
|
||||
);
|
||||
|
||||
const selectLeaderboard = React.useCallback((leaderboardId: string) => {
|
||||
dispatch({ type: 'SELECT_LEADERBOARD', payload: leaderboardId });
|
||||
}, []);
|
||||
|
||||
const setDisplayOnlyBestEntry = React.useCallback((newValue: boolean) => {
|
||||
dispatch({ type: 'CHANGE_DISPLAY_ONLY_BEST_ENTRY', payload: newValue });
|
||||
}, []);
|
||||
|
||||
const updateLeaderboard = async (attributes: {|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}) => {
|
||||
if (!currentLeaderboardId) return;
|
||||
if (attributes.sort) dispatch({ type: 'PURGE_NAVIGATION' }); // When changing playerUnicityDisplayChoice, it will change the displayOnlyBestEntry state variable, which will purge navigation.
|
||||
const updatedLeaderboard = await doUpdateLeaderboard(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
currentLeaderboardId,
|
||||
attributes
|
||||
);
|
||||
if (!updatedLeaderboard) return;
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_OR_CREATE_LEADERBOARD',
|
||||
payload: updatedLeaderboard,
|
||||
});
|
||||
|
||||
if (attributes.sort) await fetchEntries();
|
||||
};
|
||||
|
||||
const resetLeaderboard = async () => {
|
||||
if (!currentLeaderboardId) return;
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
const updatedLeaderboard = await doResetLeaderboard(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
currentLeaderboardId
|
||||
);
|
||||
if (!updatedLeaderboard) return;
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_OR_CREATE_LEADERBOARD',
|
||||
payload: updatedLeaderboard,
|
||||
});
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const deleteLeaderboard = async () => {
|
||||
if (!currentLeaderboardId || !leaderboardsByIds) return;
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
await doDeleteLeaderboard(authenticatedUser, gameId, currentLeaderboardId);
|
||||
dispatch({ type: 'REMOVE_LEADERBOARD', payload: currentLeaderboardId });
|
||||
};
|
||||
|
||||
const deleteLeaderboardEntry = async (entryId: string) => {
|
||||
if (!currentLeaderboardId) return;
|
||||
await doDeleteLeaderboardEntry(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
currentLeaderboardId,
|
||||
entryId
|
||||
);
|
||||
fetchEntries({ uri: pageIndex > 0 ? mapPageIndexToUri[pageIndex] : null });
|
||||
};
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
const navigateToNextPage = React.useCallback(
|
||||
async () => {
|
||||
const nextPageUri = mapPageIndexToUri[pageIndex + 1];
|
||||
if (!nextPageUri) return;
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: pageIndex + 1 });
|
||||
await fetchEntries({ uri: nextPageUri });
|
||||
},
|
||||
[fetchEntries, mapPageIndexToUri, pageIndex]
|
||||
);
|
||||
|
||||
const navigateToPreviousPage = React.useCallback(
|
||||
async () => {
|
||||
if (pageIndex === 1) {
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: 0 });
|
||||
await fetchEntries();
|
||||
} else {
|
||||
const previousPageUri = mapPageIndexToUri[pageIndex - 1];
|
||||
if (!previousPageUri) return;
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: pageIndex - 1 });
|
||||
await fetchEntries({ uri: previousPageUri });
|
||||
}
|
||||
},
|
||||
[fetchEntries, mapPageIndexToUri, pageIndex]
|
||||
);
|
||||
|
||||
const navigateToFirstPage = React.useCallback(
|
||||
async () => {
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: 0 });
|
||||
await fetchEntries();
|
||||
},
|
||||
[fetchEntries]
|
||||
);
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
dispatch({ type: 'SET_LEADERBOARDS', payload: null });
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
},
|
||||
[gameId]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!currentLeaderboardId) return;
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
fetchEntries();
|
||||
},
|
||||
[currentLeaderboardId, displayOnlyBestEntry, fetchEntries]
|
||||
);
|
||||
|
||||
return (
|
||||
<LeaderboardContext.Provider
|
||||
value={{
|
||||
leaderboards: !!leaderboardsByIds
|
||||
? // $FlowFixMe
|
||||
Object.values(leaderboardsByIds)
|
||||
: null,
|
||||
currentLeaderboard,
|
||||
displayOnlyBestEntry,
|
||||
browsing: {
|
||||
entries,
|
||||
goToNextPage: !!mapPageIndexToUri[pageIndex + 1]
|
||||
? navigateToNextPage
|
||||
: null,
|
||||
goToPreviousPage:
|
||||
pageIndex === 1 || !!mapPageIndexToUri[pageIndex - 1]
|
||||
? navigateToPreviousPage
|
||||
: null,
|
||||
goToFirstPage: pageIndex === 0 ? null : navigateToFirstPage,
|
||||
},
|
||||
setDisplayOnlyBestEntry,
|
||||
createLeaderboard,
|
||||
listLeaderboards,
|
||||
selectLeaderboard,
|
||||
updateLeaderboard,
|
||||
resetLeaderboard,
|
||||
deleteLeaderboard,
|
||||
deleteLeaderboardEntry,
|
||||
fetchLeaderboardEntries: fetchEntries,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LeaderboardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardProvider;
|
@@ -71,6 +71,7 @@ export const create = (authentication: Authentication) => {
|
||||
renderExportDialog={props => (
|
||||
<ExportDialog
|
||||
project={props.project}
|
||||
onSaveProject={props.onSaveProject}
|
||||
onChangeSubscription={props.onChangeSubscription}
|
||||
onClose={props.onClose}
|
||||
automatedExporters={localAutomatedExporters}
|
||||
|
@@ -47,6 +47,7 @@ export function DraggableClosableTabs({
|
||||
<DraggableClosableTab
|
||||
index={id}
|
||||
label={editorTab.label}
|
||||
icon={editorTab.icon}
|
||||
key={editorTab.key}
|
||||
id={`tab-${editorTab.key.replace(/\s/g, '-')}`}
|
||||
active={isCurrentTab}
|
||||
@@ -88,6 +89,7 @@ export function DraggableClosableTab({
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
label,
|
||||
icon,
|
||||
closable,
|
||||
onClick,
|
||||
onActivated,
|
||||
@@ -129,6 +131,7 @@ export function DraggableClosableTab({
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseAll={onCloseAll}
|
||||
label={label}
|
||||
icon={icon}
|
||||
closable={closable}
|
||||
onClick={onClick}
|
||||
onActivated={onActivated}
|
||||
|
@@ -29,7 +29,8 @@ export type EditorTab = {|
|
||||
// A reference to the editor.
|
||||
editorRef: ?EditorRef,
|
||||
// The label shown on the tab.
|
||||
label: string,
|
||||
label?: string,
|
||||
icon?: React.Node,
|
||||
// The name of the layout/external layout/external events/extension.
|
||||
projectItemName: ?string,
|
||||
// A unique key for the tab.
|
||||
@@ -56,6 +57,7 @@ export const openEditorTab = (
|
||||
state: EditorTabsState,
|
||||
{
|
||||
label,
|
||||
icon,
|
||||
projectItemName,
|
||||
renderEditorContainer,
|
||||
key,
|
||||
@@ -63,7 +65,8 @@ export const openEditorTab = (
|
||||
dontFocusTab,
|
||||
closable,
|
||||
}: {|
|
||||
label: string,
|
||||
label?: string,
|
||||
icon?: React.Node,
|
||||
projectItemName: ?string,
|
||||
renderEditorContainer: (
|
||||
props: RenderEditorContainerPropsWithRef
|
||||
@@ -87,6 +90,7 @@ export const openEditorTab = (
|
||||
|
||||
const editorTab: EditorTab = {
|
||||
label,
|
||||
icon,
|
||||
projectItemName,
|
||||
renderEditorContainer,
|
||||
key,
|
||||
|
@@ -14,6 +14,7 @@ export type AlertMessageIdentifier =
|
||||
| 'automatic-lighting-layer'
|
||||
| 'object-moved-in-lighting-layer'
|
||||
| 'use-non-smoothed-textures'
|
||||
| 'use-pixel-rounding'
|
||||
| 'use-nearest-scale-mode'
|
||||
| 'maximum-fps-too-low'
|
||||
| 'minimum-fps-too-low'
|
||||
@@ -53,6 +54,10 @@ export const allAlertMessages: Array<{
|
||||
key: 'use-non-smoothed-textures',
|
||||
label: <Trans>Using non smoothed textures</Trans>,
|
||||
},
|
||||
{
|
||||
key: 'use-pixel-rounding',
|
||||
label: <Trans>Using pixel rounding</Trans>,
|
||||
},
|
||||
{
|
||||
key: 'use-nearest-scale-mode',
|
||||
label: <Trans>Using Nearest Scale Mode</Trans>,
|
||||
|
@@ -5,6 +5,7 @@ import * as React from 'react';
|
||||
import './MainFrame.css';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import Toolbar, { type ToolbarInterface } from './Toolbar';
|
||||
import ProjectTitlebar from './ProjectTitlebar';
|
||||
import PreferencesDialog from './Preferences/PreferencesDialog';
|
||||
@@ -132,6 +133,7 @@ import {
|
||||
} from '../Utils/GDevelopServices/Badge';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import OnboardingDialog from './Onboarding/OnboardingDialog';
|
||||
import LeaderboardProvider from '../Leaderboard/LeaderboardProvider';
|
||||
|
||||
const GD_STARTUP_TIMES = global.GD_STARTUP_TIMES || [];
|
||||
|
||||
@@ -1377,7 +1379,7 @@ const MainFrame = (props: Props) => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
editorTabs: openEditorTab(state.editorTabs, {
|
||||
label: i18n._(t`Home`),
|
||||
icon: <HomeIcon role="img" titleAccess="Home" />,
|
||||
projectItemName: null,
|
||||
renderEditorContainer: renderHomePageContainer,
|
||||
key: 'start page',
|
||||
@@ -1385,7 +1387,7 @@ const MainFrame = (props: Props) => {
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[i18n, setState]
|
||||
[setState]
|
||||
);
|
||||
|
||||
const _openDebugger = React.useCallback(
|
||||
@@ -2162,86 +2164,92 @@ const MainFrame = (props: Props) => {
|
||||
onTabActived={(editorTab: EditorTab) => _onEditorTabActived(editorTab)}
|
||||
onDropTab={onDropEditorTab}
|
||||
/>
|
||||
{getEditors(state.editorTabs).map((editorTab, id) => {
|
||||
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
|
||||
return (
|
||||
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
|
||||
<CommandsContextScopedProvider active={isCurrentTab}>
|
||||
<ErrorBoundary>
|
||||
{editorTab.renderEditorContainer({
|
||||
isActive: isCurrentTab,
|
||||
extraEditorProps: editorTab.extraEditorProps,
|
||||
project: currentProject,
|
||||
ref: editorRef => (editorTab.editorRef = editorRef),
|
||||
setToolbar: editorToolbar =>
|
||||
setEditorToolbar(editorToolbar, isCurrentTab),
|
||||
onChangeSubscription: () => openSubscriptionDialog(true),
|
||||
projectItemName: editorTab.projectItemName,
|
||||
setPreviewedLayout,
|
||||
onOpenExternalEvents: openExternalEvents,
|
||||
onOpenEvents: (sceneName: string) =>
|
||||
openLayout(sceneName, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
previewDebuggerServer,
|
||||
hotReloadPreviewButtonProps,
|
||||
onOpenLayout: name =>
|
||||
openLayout(name, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
resourceSources: props.resourceSources,
|
||||
onChooseResource,
|
||||
resourceExternalEditors,
|
||||
onCreateEventsFunction,
|
||||
openInstructionOrExpression,
|
||||
unsavedChanges: unsavedChanges,
|
||||
canOpen: !!props.storageProviders.filter(
|
||||
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
|
||||
).length,
|
||||
onOpen: () => chooseProject(),
|
||||
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
|
||||
onCreateFromExampleShortHeader: onCreateFromExampleShortHeader,
|
||||
onCreateBlank: onCreateBlank,
|
||||
onOpenProjectAfterCreation: onOpenProjectAfterCreation,
|
||||
onOpenProjectManager: () => openProjectManager(true),
|
||||
onCloseProject: () => askToCloseProject(),
|
||||
onOpenTutorials: () => onOpenTutorials(),
|
||||
onOpenGamesShowcase: () => onOpenGamesShowcase(),
|
||||
onOpenExamples: () => onOpenExamples(),
|
||||
onOpenProfile: () => openProfileDialogWithTab('profile'),
|
||||
onOpenHelpFinder: () => openHelpFinderDialog(true),
|
||||
onOpenLanguageDialog: () => openLanguageDialog(true),
|
||||
onLoadEventsFunctionsExtensions: () =>
|
||||
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
|
||||
currentProject
|
||||
),
|
||||
onDeleteResource: (
|
||||
resource: gdResource,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
// TODO: Project wide refactoring of objects/events using the resource
|
||||
cb(true);
|
||||
},
|
||||
onRenameResource: (
|
||||
resource: gdResource,
|
||||
newName: string,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
if (currentProject)
|
||||
renameResourcesInProject(currentProject, {
|
||||
[resource.getName()]: newName,
|
||||
});
|
||||
<LeaderboardProvider
|
||||
gameId={
|
||||
state.currentProject ? state.currentProject.getProjectUuid() : ''
|
||||
}
|
||||
>
|
||||
{getEditors(state.editorTabs).map((editorTab, id) => {
|
||||
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
|
||||
return (
|
||||
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
|
||||
<CommandsContextScopedProvider active={isCurrentTab}>
|
||||
<ErrorBoundary>
|
||||
{editorTab.renderEditorContainer({
|
||||
isActive: isCurrentTab,
|
||||
extraEditorProps: editorTab.extraEditorProps,
|
||||
project: currentProject,
|
||||
ref: editorRef => (editorTab.editorRef = editorRef),
|
||||
setToolbar: editorToolbar =>
|
||||
setEditorToolbar(editorToolbar, isCurrentTab),
|
||||
onChangeSubscription: () => openSubscriptionDialog(true),
|
||||
projectItemName: editorTab.projectItemName,
|
||||
setPreviewedLayout,
|
||||
onOpenExternalEvents: openExternalEvents,
|
||||
onOpenEvents: (sceneName: string) =>
|
||||
openLayout(sceneName, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
previewDebuggerServer,
|
||||
hotReloadPreviewButtonProps,
|
||||
onOpenLayout: name =>
|
||||
openLayout(name, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
resourceSources: props.resourceSources,
|
||||
onChooseResource,
|
||||
resourceExternalEditors,
|
||||
onCreateEventsFunction,
|
||||
openInstructionOrExpression,
|
||||
unsavedChanges: unsavedChanges,
|
||||
canOpen: !!props.storageProviders.filter(
|
||||
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
|
||||
).length,
|
||||
onOpen: () => chooseProject(),
|
||||
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
|
||||
onCreateFromExampleShortHeader: onCreateFromExampleShortHeader,
|
||||
onCreateBlank: onCreateBlank,
|
||||
onOpenProjectAfterCreation: onOpenProjectAfterCreation,
|
||||
onOpenProjectManager: () => openProjectManager(true),
|
||||
onCloseProject: () => askToCloseProject(),
|
||||
onOpenTutorials: () => onOpenTutorials(),
|
||||
onOpenGamesShowcase: () => onOpenGamesShowcase(),
|
||||
onOpenExamples: () => onOpenExamples(),
|
||||
onOpenProfile: () => openProfileDialogWithTab('profile'),
|
||||
onOpenHelpFinder: () => openHelpFinderDialog(true),
|
||||
onOpenLanguageDialog: () => openLanguageDialog(true),
|
||||
onLoadEventsFunctionsExtensions: () =>
|
||||
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
|
||||
currentProject
|
||||
),
|
||||
onDeleteResource: (
|
||||
resource: gdResource,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
// TODO: Project wide refactoring of objects/events using the resource
|
||||
cb(true);
|
||||
},
|
||||
onRenameResource: (
|
||||
resource: gdResource,
|
||||
newName: string,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
if (currentProject)
|
||||
renameResourcesInProject(currentProject, {
|
||||
[resource.getName()]: newName,
|
||||
});
|
||||
|
||||
cb(true);
|
||||
},
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</CommandsContextScopedProvider>
|
||||
</TabContentContainer>
|
||||
);
|
||||
})}
|
||||
cb(true);
|
||||
},
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</CommandsContextScopedProvider>
|
||||
</TabContentContainer>
|
||||
);
|
||||
})}
|
||||
</LeaderboardProvider>
|
||||
<CommandPalette ref={commandPaletteRef} />
|
||||
<LoaderModal show={showLoader} />
|
||||
<HelpFinder
|
||||
@@ -2266,6 +2274,7 @@ const MainFrame = (props: Props) => {
|
||||
openSubscriptionDialog(true);
|
||||
},
|
||||
project: state.currentProject,
|
||||
onSaveProject: saveProject,
|
||||
})}
|
||||
{!!renderCreateDialog &&
|
||||
state.createDialogOpen &&
|
||||
|
@@ -3,6 +3,7 @@ import { Trans, t } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import OpenInNew from '@material-ui/icons/OpenInNew';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
import { ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import PlaceholderLoader from '../UI/PlaceholderLoader';
|
||||
@@ -14,8 +15,11 @@ import PlaceholderError from '../UI/PlaceholderError';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import UserAchievements from './Achievement/UserAchievements';
|
||||
import { type Badge } from '../Utils/GDevelopServices/Badge';
|
||||
import Window from '../Utils/Window';
|
||||
import { GDevelopGamesPlatform } from '../Utils/GDevelopServices/ApiConfigs';
|
||||
|
||||
type DisplayedProfile = {
|
||||
id: string,
|
||||
+email?: string,
|
||||
description: ?string,
|
||||
username: ?string,
|
||||
@@ -44,21 +48,41 @@ const ProfileDetails = ({
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Column>
|
||||
<Line alignItems="center">
|
||||
<Avatar src={getGravatarUrl(profile.email || '', { size: 40 })} />
|
||||
<Spacer />
|
||||
<Text
|
||||
size="title"
|
||||
style={{
|
||||
opacity: profile.username ? 1.0 : 0.5,
|
||||
}}
|
||||
>
|
||||
{profile.username ||
|
||||
(isAuthenticatedUserProfile
|
||||
? i18n._(t`Edit your profile to pick a username!`)
|
||||
: i18n._(t`No username`))}
|
||||
</Text>
|
||||
</Line>
|
||||
<ResponsiveLineStackLayout
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
noColumnMargin
|
||||
>
|
||||
<Line>
|
||||
<Avatar src={getGravatarUrl(profile.email || '', { size: 40 })} />
|
||||
<Spacer />
|
||||
<Text
|
||||
size="title"
|
||||
style={{
|
||||
opacity: profile.username ? 1.0 : 0.5,
|
||||
}}
|
||||
>
|
||||
{profile.username ||
|
||||
(isAuthenticatedUserProfile
|
||||
? i18n._(t`Edit your profile to pick a username!`)
|
||||
: i18n._(t`No username`))}
|
||||
</Text>
|
||||
</Line>
|
||||
{profile.id && (
|
||||
<RaisedButton
|
||||
label={i18n._(t`Access public profile`)}
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
GDevelopGamesPlatform.getUserPublicProfileUrl(
|
||||
profile.id,
|
||||
profile.username
|
||||
)
|
||||
)
|
||||
}
|
||||
icon={<OpenInNew />}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveLineStackLayout>
|
||||
{isAuthenticatedUserProfile && profile.email && (
|
||||
<Line>
|
||||
<TextField
|
||||
|
@@ -80,6 +80,7 @@ const ProfileDialog = ({
|
||||
cannotBeDismissed={false}
|
||||
open={open}
|
||||
noMargin
|
||||
fullHeight
|
||||
noTitleMargin
|
||||
title={
|
||||
<Tabs value={currentTab} onChange={_onChangeTab}>
|
||||
@@ -113,7 +114,7 @@ const ProfileDialog = ({
|
||||
{currentTab === 'games-dashboard' &&
|
||||
(authenticatedUser.authenticated ? (
|
||||
<Line>
|
||||
<ColumnStackLayout expand>
|
||||
<ColumnStackLayout expand noOverflowParent>
|
||||
<GamesList project={currentProject} />
|
||||
</ColumnStackLayout>
|
||||
</Line>
|
||||
|
@@ -50,7 +50,7 @@ function InstalledExtensionDetails({
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ExtensionInstallDialog
|
||||
alreadyInstalled
|
||||
project={project}
|
||||
isInstalling={isInstalling}
|
||||
onClose={onClose}
|
||||
onInstall={() => installOrUpdateExtension(i18n)}
|
||||
|
@@ -36,7 +36,7 @@ import {
|
||||
type HotReloadPreviewButtonProps,
|
||||
NewPreviewIcon,
|
||||
} from '../HotReload/HotReloadPreviewButton';
|
||||
import PublicGameProperties from './PublicGameProperties';
|
||||
import PublicGameProperties from '../GameDashboard/PublicGameProperties';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
@@ -541,6 +541,17 @@ function ProjectPropertiesDialog(props: Props) {
|
||||
</Trans>
|
||||
</DismissableAlertMessage>
|
||||
)}
|
||||
{pixelsRounding && (
|
||||
<DismissableAlertMessage
|
||||
identifier="use-pixel-rounding"
|
||||
kind="info"
|
||||
>
|
||||
<Trans>
|
||||
To avoid flickering on objects followed by the camera, use
|
||||
sprites with even dimensions.
|
||||
</Trans>
|
||||
</DismissableAlertMessage>
|
||||
)}
|
||||
|
||||
<Text size="title">
|
||||
<Trans>Project files</Trans>
|
||||
|
@@ -1,187 +0,0 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import SemiControlledTextField from '../UI/SemiControlledTextField';
|
||||
import { UsersAutocomplete } from '../Utils/UsersAutocomplete';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import Checkbox from '../UI/Checkbox';
|
||||
import SelectField from '../UI/SelectField';
|
||||
import SelectOption from '../UI/SelectOption';
|
||||
import { t } from '@lingui/macro';
|
||||
import SemiControlledMultiAutoComplete from '../UI/SemiControlledMultiAutoComplete';
|
||||
import {
|
||||
allGameCategories,
|
||||
getCategoryName,
|
||||
} from '../Utils/GDevelopServices/Game';
|
||||
import { I18n } from '@lingui/react';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
setName: string => void,
|
||||
name: string,
|
||||
setCategories?: (string[]) => void,
|
||||
categories?: string[],
|
||||
setDescription: string => void,
|
||||
description: ?string,
|
||||
setAuthorIds: (string[]) => void,
|
||||
authorIds: string[],
|
||||
setOwnerIds?: (string[]) => void,
|
||||
ownerIds?: string[],
|
||||
setPlayableWithKeyboard?: boolean => void,
|
||||
playWithKeyboard?: boolean,
|
||||
setPlayableWithGamepad?: boolean => void,
|
||||
playWithGamepad?: boolean,
|
||||
setPlayableWithMobile?: boolean => void,
|
||||
playWithMobile?: boolean,
|
||||
setOrientation: string => void,
|
||||
orientation: string,
|
||||
|};
|
||||
|
||||
function PublicGameProperties({
|
||||
project,
|
||||
setName,
|
||||
name,
|
||||
categories,
|
||||
setCategories,
|
||||
setDescription,
|
||||
description,
|
||||
setAuthorIds,
|
||||
authorIds,
|
||||
setOwnerIds,
|
||||
ownerIds,
|
||||
setPlayableWithKeyboard,
|
||||
playWithKeyboard,
|
||||
setPlayableWithGamepad,
|
||||
playWithGamepad,
|
||||
setPlayableWithMobile,
|
||||
playWithMobile,
|
||||
setOrientation,
|
||||
orientation,
|
||||
}: Props) {
|
||||
const [categoryInput, setCategoryInput] = React.useState('');
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ColumnStackLayout noMargin>
|
||||
<SemiControlledTextField
|
||||
floatingLabelText={<Trans>Game name</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
autoFocus
|
||||
/>
|
||||
{setCategories && (
|
||||
<SemiControlledMultiAutoComplete
|
||||
hintText={t`Select a genre`}
|
||||
floatingLabelText={<Trans>Genres</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select up to 4 genres, the first one will define the game's
|
||||
main genre
|
||||
</Trans>
|
||||
}
|
||||
value={
|
||||
categories
|
||||
? categories.map(category => ({
|
||||
value: category,
|
||||
text: getCategoryName(category, i18n),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onChange={(event, values) => {
|
||||
setCategories(
|
||||
values ? values.map(category => category.value) : []
|
||||
);
|
||||
}}
|
||||
inputValue={categoryInput}
|
||||
onInputChange={(event, value) => {
|
||||
setCategoryInput(value);
|
||||
}}
|
||||
dataSource={allGameCategories.map(category => ({
|
||||
value: category,
|
||||
text: getCategoryName(category, i18n),
|
||||
}))}
|
||||
fullWidth
|
||||
optionsLimit={4}
|
||||
/>
|
||||
)}
|
||||
<SemiControlledTextField
|
||||
floatingLabelText={<Trans>Game description</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={description || ''}
|
||||
onChange={setDescription}
|
||||
autoFocus
|
||||
multiline
|
||||
rows={5}
|
||||
/>
|
||||
<UsersAutocomplete
|
||||
userIds={authorIds}
|
||||
onChange={setAuthorIds}
|
||||
floatingLabelText={<Trans>Authors</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select the usernames of the authors of this project. They will
|
||||
be displayed in the selected order, if you publish this game as
|
||||
an example or in the community.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
{// This view is used for public game properties as well as project properties.
|
||||
// This property is not shown in project properties.
|
||||
setOwnerIds && (
|
||||
<UsersAutocomplete
|
||||
userIds={ownerIds || []}
|
||||
onChange={setOwnerIds}
|
||||
floatingLabelText={<Trans>Owners</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select the usernames of the owners of this project to let them
|
||||
manage this game builds. Be aware that owners can revoke your
|
||||
ownership.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>Device orientation (for mobile)</Trans>}
|
||||
value={orientation}
|
||||
onChange={(e, i, value: string) => setOrientation(value)}
|
||||
>
|
||||
<SelectOption value="default" primaryText={t`Platform default`} />
|
||||
<SelectOption value="landscape" primaryText={t`Landscape`} />
|
||||
<SelectOption value="portrait" primaryText={t`Portrait`} />
|
||||
</SelectField>
|
||||
{// This view is used for public game properties as well as project properties.
|
||||
// The following properties are not shown in project properties.
|
||||
setPlayableWithKeyboard &&
|
||||
setPlayableWithGamepad &&
|
||||
setPlayableWithMobile && (
|
||||
<React.Fragment>
|
||||
<Checkbox
|
||||
label={<Trans>Playable with a keyboard</Trans>}
|
||||
checked={!!playWithKeyboard}
|
||||
onCheck={(e, checked) => setPlayableWithKeyboard(checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Playable with a gamepad</Trans>}
|
||||
checked={!!playWithGamepad}
|
||||
onCheck={(e, checked) => setPlayableWithGamepad(checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Playable on mobile</Trans>}
|
||||
checked={!!playWithMobile}
|
||||
onCheck={(e, checked) => setPlayableWithMobile(checked)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicGameProperties;
|
@@ -2,6 +2,16 @@
|
||||
import * as React from 'react';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
// Reduce checkbox size to avoid overlapping with other checkboxes.
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
marginLeft: 9,
|
||||
marginRight: 9,
|
||||
padding: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// We support a subset of the props supported by Material-UI v0.x Checkbox
|
||||
// They should be self descriptive - refer to Material UI docs otherwise.
|
||||
@@ -25,8 +35,10 @@ type Props = {|
|
||||
*/
|
||||
export default (props: Props) => {
|
||||
const { onCheck } = props;
|
||||
const classes = useStyles();
|
||||
const checkbox = (
|
||||
<Checkbox
|
||||
className={classes.root}
|
||||
disabled={props.disabled}
|
||||
checked={props.checked}
|
||||
onChange={
|
||||
|
@@ -1,20 +1,15 @@
|
||||
// @flow
|
||||
import { t } from '@lingui/macro';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import React, { Component, useEffect, type Node, useRef } from 'react';
|
||||
import * as React from 'react';
|
||||
import Close from '@material-ui/icons/Close';
|
||||
import ButtonBase from '@material-ui/core/ButtonBase';
|
||||
import ThemeConsumer from './Theme/ThemeConsumer';
|
||||
import ContextMenu, { type ContextMenuInterface } from './Menu/ContextMenu';
|
||||
import { useLongTouch } from '../Utils/UseLongTouch';
|
||||
import { Spacer } from './Grid';
|
||||
|
||||
const styles = {
|
||||
tabsContainerStyle: {
|
||||
maxWidth: '100%',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
tabContentContainer: {
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
@@ -24,19 +19,19 @@ const styles = {
|
||||
flex: 1,
|
||||
},
|
||||
tabLabel: {
|
||||
maxWidth: 400,
|
||||
maxWidth: 360,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 6,
|
||||
marginBottom: 6,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
fontSize: '15px', // Same as in Mosaic.css (for mosaic-window-title)
|
||||
},
|
||||
tabLabelAndIcon: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
},
|
||||
closeButton: {
|
||||
marginTop: 7,
|
||||
marginBottom: 7,
|
||||
marginRight: 5,
|
||||
marginLeft: 5,
|
||||
},
|
||||
@@ -44,7 +39,7 @@ const styles = {
|
||||
|
||||
type TabContentContainerProps = {|
|
||||
active: boolean,
|
||||
children: Node,
|
||||
children: React.Node,
|
||||
|};
|
||||
|
||||
/**
|
||||
@@ -56,7 +51,7 @@ type TabContentContainerProps = {|
|
||||
*
|
||||
* 2) shouldComponentUpdate is used to avoid updating the content of a tab that is not selected.
|
||||
*/
|
||||
export class TabContentContainer extends Component<TabContentContainerProps> {
|
||||
export class TabContentContainer extends React.Component<TabContentContainerProps> {
|
||||
shouldComponentUpdate(nextProps: TabContentContainerProps) {
|
||||
return this.props.active || nextProps.active;
|
||||
}
|
||||
@@ -78,7 +73,7 @@ export class TabContentContainer extends Component<TabContentContainerProps> {
|
||||
|
||||
type ClosableTabsProps = {|
|
||||
hideLabels?: boolean,
|
||||
children: Node,
|
||||
children: React.Node,
|
||||
|};
|
||||
|
||||
export const ClosableTabs = ({ hideLabels, children }: ClosableTabsProps) => (
|
||||
@@ -101,7 +96,8 @@ export const ClosableTabs = ({ hideLabels, children }: ClosableTabsProps) => (
|
||||
export type ClosableTabProps = {|
|
||||
id?: string,
|
||||
active: boolean,
|
||||
label: Node,
|
||||
label: ?React.Node,
|
||||
icon: ?React.Node,
|
||||
closable: boolean,
|
||||
onClose: () => void,
|
||||
onCloseOthers: () => void,
|
||||
@@ -117,11 +113,12 @@ export function ClosableTab({
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
label,
|
||||
icon,
|
||||
closable,
|
||||
onClick,
|
||||
onActivated,
|
||||
}: ClosableTabProps) {
|
||||
useEffect(
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (active) {
|
||||
onActivated();
|
||||
@@ -129,7 +126,7 @@ export function ClosableTab({
|
||||
},
|
||||
[active, onActivated]
|
||||
);
|
||||
const contextMenu = useRef<?ContextMenuInterface>(null);
|
||||
const contextMenu = React.useRef<?ContextMenuInterface>(null);
|
||||
|
||||
const openContextMenu = event => {
|
||||
event.stopPropagation();
|
||||
@@ -192,12 +189,15 @@ export function ClosableTab({
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...styles.tabLabel,
|
||||
...styles.tabLabelAndIcon,
|
||||
height: muiTheme.closableTabs.height,
|
||||
color: textColor,
|
||||
fontFamily: muiTheme.closableTabs.fontFamily,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{icon}
|
||||
{icon && label ? <Spacer /> : null}
|
||||
{label && <span style={styles.tabLabel}>{label}</span>}
|
||||
</span>
|
||||
</ButtonBase>
|
||||
{closable && (
|
||||
@@ -212,7 +212,7 @@ export function ClosableTab({
|
||||
style={{
|
||||
...styles.closeButton,
|
||||
width: muiTheme.closableTabs.height / 2,
|
||||
height: muiTheme.closableTabs.height / 2,
|
||||
height: muiTheme.closableTabs.height,
|
||||
}}
|
||||
htmlColor={textColor}
|
||||
/>
|
||||
|
@@ -20,9 +20,9 @@ const styles = {
|
||||
padding: 0,
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
flexRowBody: {
|
||||
flexColumnBody: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
flexBody: {
|
||||
display: 'flex',
|
||||
@@ -70,7 +70,7 @@ type Props = {|
|
||||
children: React.Node, // The content of the dialog
|
||||
|
||||
// Display:
|
||||
flexRowBody?: boolean, //Check if necessary
|
||||
flexColumnBody?: boolean,
|
||||
flexBody?: boolean,
|
||||
|
||||
// Size
|
||||
@@ -89,7 +89,7 @@ type DialogContentStyle = {
|
||||
padding?: 0,
|
||||
overflowX?: 'hidden',
|
||||
display?: 'flex',
|
||||
flexDirection?: 'row',
|
||||
flexDirection?: 'row' | 'column',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -107,7 +107,7 @@ export default (props: Props) => {
|
||||
noMargin,
|
||||
title,
|
||||
children,
|
||||
flexRowBody,
|
||||
flexColumnBody,
|
||||
flexBody,
|
||||
fullHeight,
|
||||
noTitleMargin,
|
||||
@@ -132,7 +132,7 @@ export default (props: Props) => {
|
||||
|
||||
const dialogContentStyle: DialogContentStyle = {
|
||||
...(noMargin ? styles.noMarginBody : styles.defaultBody),
|
||||
...((flexRowBody ? styles.flexRowBody : {}): DialogContentStyle),
|
||||
...((flexColumnBody ? styles.flexColumnBody : {}): DialogContentStyle),
|
||||
...((flexBody ? styles.flexBody : {}): DialogContentStyle),
|
||||
};
|
||||
|
||||
|
@@ -1,8 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import ThemeConsumer from './Theme/ThemeConsumer';
|
||||
import BackgroundText from './BackgroundText';
|
||||
// No i18n in this file
|
||||
|
||||
const styles = {
|
||||
containerStyle: {
|
||||
@@ -25,15 +23,9 @@ type Props = {|
|
||||
* Also take a look at EmptyPlaceholder for a more visible placerholder.
|
||||
*/
|
||||
const EmptyMessage = (props: Props) => (
|
||||
<ThemeConsumer>
|
||||
{muiTheme => (
|
||||
<div style={{ ...styles.containerStyle, ...props.style }}>
|
||||
<BackgroundText style={props.messageStyle}>
|
||||
{props.children}
|
||||
</BackgroundText>
|
||||
</div>
|
||||
)}
|
||||
</ThemeConsumer>
|
||||
<div style={{ ...styles.containerStyle, ...props.style }}>
|
||||
<BackgroundText style={props.messageStyle}>{props.children}</BackgroundText>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default EmptyMessage;
|
||||
|
@@ -1,21 +1,23 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Container from '@material-ui/core/Container';
|
||||
import { CircularProgress } from '@material-ui/core';
|
||||
import Add from '@material-ui/icons/Add';
|
||||
import { ColumnStackLayout } from './Layout';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import { Column, LargeSpacer } from './Grid';
|
||||
import HelpButton from '../UI/HelpButton';
|
||||
import Text from '../UI/Text';
|
||||
import Add from '@material-ui/icons/Add';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import Container from '@material-ui/core/Container';
|
||||
import { ColumnStackLayout } from './Layout';
|
||||
|
||||
type Props = {|
|
||||
title: React.Node,
|
||||
description: React.Node,
|
||||
actionLabel: React.Node,
|
||||
helpPagePath: string,
|
||||
helpPagePath?: string,
|
||||
actionButtonId?: string,
|
||||
onAdd: () => void,
|
||||
isLoading?: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
@@ -43,7 +45,8 @@ export const EmptyPlaceholder = (props: Props) => (
|
||||
label={props.actionLabel}
|
||||
primary
|
||||
onClick={props.onAdd}
|
||||
icon={<Add />}
|
||||
disabled={!!props.isLoading}
|
||||
icon={props.isLoading ? <CircularProgress size={24} /> : <Add />}
|
||||
id={props.actionButtonId}
|
||||
/>
|
||||
<HelpButton
|
||||
|
@@ -25,7 +25,7 @@ type IconProps =
|
||||
// They should be self descriptive - refer to Material UI docs otherwise.
|
||||
type Props = {|
|
||||
...IconProps,
|
||||
onClick?: (ev: any) => void,
|
||||
onClick?: (ev: any) => void | Promise<void>,
|
||||
onContextMenu?: () => void,
|
||||
disabled?: boolean,
|
||||
edge?: 'start' | 'end' | false,
|
||||
|
@@ -132,6 +132,7 @@ type ColumnStackLayoutProps = {|
|
||||
expand?: boolean,
|
||||
noMargin?: boolean,
|
||||
children: React.Node,
|
||||
noOverflowParent?: boolean,
|
||||
|};
|
||||
|
||||
export const ColumnStackLayout = ({
|
||||
@@ -140,6 +141,7 @@ export const ColumnStackLayout = ({
|
||||
expand,
|
||||
noMargin,
|
||||
children,
|
||||
noOverflowParent,
|
||||
}: ColumnStackLayoutProps) => {
|
||||
let isFirstChild = true;
|
||||
return (
|
||||
@@ -148,6 +150,7 @@ export const ColumnStackLayout = ({
|
||||
justifyContent={justifyContent}
|
||||
expand={expand}
|
||||
noMargin={noMargin}
|
||||
noOverflowParent={noOverflowParent}
|
||||
>
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (!child) return null;
|
||||
|
@@ -11,7 +11,7 @@ export type MenuItemTemplate =
|
||||
visible?: boolean,
|
||||
enabled?: boolean,
|
||||
disabled?: boolean,
|
||||
click?: ?() => void,
|
||||
click?: ?() => void | Promise<void>,
|
||||
accelerator?: string,
|
||||
|}
|
||||
// Sub menu
|
||||
@@ -26,7 +26,7 @@ export type MenuItemTemplate =
|
||||
visible?: boolean,
|
||||
enabled?: boolean,
|
||||
checked: boolean,
|
||||
click?: ?() => void,
|
||||
click?: ?() => void | (() => Promise<void>),
|
||||
|}
|
||||
// A separator
|
||||
| {| type: 'separator' |};
|
||||
|
@@ -4,27 +4,32 @@ import * as React from 'react';
|
||||
import { Column, Line } from './Grid';
|
||||
import RaisedButton from './RaisedButton';
|
||||
import EmptyMessage from './EmptyMessage';
|
||||
import GDevelopThemeContext from './Theme/ThemeContext';
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
onRetry?: () => void | Promise<void>,
|
||||
kind?: 'error' | 'valid' | 'warning',
|
||||
|};
|
||||
|
||||
const PlaceholderError = ({ onRetry, children }: Props) => (
|
||||
<Column expand alignItems="center">
|
||||
<EmptyMessage>{children}</EmptyMessage>
|
||||
<Line>
|
||||
{onRetry && (
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Retry</Trans>}
|
||||
onClick={() => {
|
||||
onRetry();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
</Column>
|
||||
);
|
||||
const PlaceholderError = ({ onRetry, children, kind }: Props) => {
|
||||
const theme = React.useContext(GDevelopThemeContext);
|
||||
return (
|
||||
<Column expand alignItems="center">
|
||||
<EmptyMessage style={kind ? { color: theme.message[kind] } : undefined}>
|
||||
{children}
|
||||
</EmptyMessage>
|
||||
<Line>
|
||||
{onRetry && (
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Retry</Trans>}
|
||||
onClick={onRetry}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaceholderError;
|
||||
|
@@ -15,6 +15,14 @@ type Props = {|
|
||||
icon?: React.Node,
|
||||
onClick: ?() => void,
|
||||
buildMenuTemplate: (i18n: I18nType) => Array<MenuItemTemplate>,
|
||||
style?: {|
|
||||
marginTop?: number,
|
||||
marginBottom?: number,
|
||||
marginLeft?: number,
|
||||
marginRight?: number,
|
||||
margin?: number,
|
||||
flexShrink?: 0,
|
||||
|},
|
||||
|};
|
||||
|
||||
const shouldNeverBeCalled = () => {
|
||||
@@ -46,6 +54,7 @@ const RaisedButtonWithSplitMenu = (props: Props) => {
|
||||
color={primary ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
style={props.style}
|
||||
>
|
||||
<Button
|
||||
focusRipple={focusRipple}
|
||||
|
@@ -29,6 +29,7 @@ type Props = {|
|
||||
flex?: 1,
|
||||
width?: 'auto',
|
||||
},
|
||||
inputStyle?: {| fontSize: 14 |},
|
||||
margin?: 'none' | 'dense',
|
||||
|
||||
floatingLabelText?: React.Node,
|
||||
@@ -96,6 +97,7 @@ export default class SelectField extends React.Component<Props, {||}> {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
InputProps={{ style: props.inputStyle }}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
|
@@ -53,6 +53,7 @@ export default function SemiControlledMultiAutoComplete(props: Props) {
|
||||
) ||
|
||||
(props.optionsLimit && props.value.length >= props.optionsLimit)
|
||||
}
|
||||
getOptionSelected={(option, value) => option.value === value.value}
|
||||
loading={props.loading}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
|
@@ -7,6 +7,8 @@ type Props = {|
|
||||
children: ?React.Node,
|
||||
/** Size of the text. `body` if not specified. */
|
||||
size?: 'body' | 'body2' | 'title' | 'bold-title',
|
||||
/** Color of the text */
|
||||
color?: 'error' | 'primary' | 'secondary',
|
||||
/** The text alignment. */
|
||||
align?: 'inherit' | 'left' | 'center' | 'right' | 'justify',
|
||||
/** Don't shrink the text if there is not enough place in a flex container. */
|
||||
@@ -35,38 +37,52 @@ type Props = {|
|
||||
|},
|
||||
|};
|
||||
|
||||
type Interface = {||};
|
||||
|
||||
// A Text to be displayed in the app. Prefer using this
|
||||
// than a `<p>`/`<span>` or `<div>` as this will help to maintain
|
||||
// consistency of text in the whole app.
|
||||
export default ({
|
||||
children,
|
||||
style,
|
||||
size,
|
||||
align,
|
||||
noShrink,
|
||||
noMargin,
|
||||
displayInlineAsSpan,
|
||||
}: Props) => (
|
||||
<Typography
|
||||
variant={
|
||||
size === 'bold-title'
|
||||
? 'h5'
|
||||
: size === 'title'
|
||||
? 'h6'
|
||||
: size === 'body2'
|
||||
? 'body2'
|
||||
: 'body1'
|
||||
}
|
||||
component={displayInlineAsSpan ? 'span' : undefined}
|
||||
style={{
|
||||
...style,
|
||||
display: displayInlineAsSpan ? 'inline-block' : undefined,
|
||||
flexShrink: noShrink ? 0 : undefined,
|
||||
marginTop: noMargin ? 0 : 6,
|
||||
marginBottom: noMargin ? 0 : 6,
|
||||
}}
|
||||
align={align || 'inherit'}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
const Text = React.forwardRef<Props, Interface>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
size,
|
||||
color,
|
||||
align,
|
||||
noShrink,
|
||||
noMargin,
|
||||
displayInlineAsSpan,
|
||||
...otherProps // Used by possible parent element (such as Tooltip) to pass down props.
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<Typography
|
||||
variant={
|
||||
size === 'bold-title'
|
||||
? 'h5'
|
||||
: size === 'title'
|
||||
? 'h6'
|
||||
: size === 'body2'
|
||||
? 'body2'
|
||||
: 'body1'
|
||||
}
|
||||
ref={ref}
|
||||
color={color}
|
||||
component={displayInlineAsSpan ? 'span' : undefined}
|
||||
style={{
|
||||
...style,
|
||||
display: displayInlineAsSpan ? 'inline-block' : undefined,
|
||||
flexShrink: noShrink ? 0 : undefined,
|
||||
marginTop: noMargin ? 0 : 6,
|
||||
marginBottom: noMargin ? 0 : 6,
|
||||
}}
|
||||
align={align || 'inherit'}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
);
|
||||
|
||||
export default Text;
|
||||
|
@@ -71,6 +71,9 @@ type Props = {|
|
||||
// Keyboard focus:
|
||||
autoFocus?: boolean,
|
||||
|
||||
// String text field:
|
||||
maxLength?: number,
|
||||
|
||||
// Number text field:
|
||||
precision?: number,
|
||||
max?: number,
|
||||
@@ -248,6 +251,8 @@ export default class TextField extends React.Component<Props, {||}> {
|
||||
onKeyUp: props.onKeyUp,
|
||||
onKeyDown: props.onKeyDown,
|
||||
onClick: props.onClick,
|
||||
// String field props:
|
||||
maxLength: props.maxLength,
|
||||
// Number field props:
|
||||
max: props.max,
|
||||
min: props.min,
|
||||
|
@@ -1,5 +1,7 @@
|
||||
// @flow
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
import Window from '../Window';
|
||||
|
||||
const isDev = Window.isDev();
|
||||
|
||||
export const GDevelopGamePreviews = {
|
||||
baseUrl: `https://game-previews.gdevelop-app.com/`,
|
||||
@@ -14,6 +16,14 @@ export const GDevelopGamesPlatform = {
|
||||
isDev
|
||||
? `https://liluo.io/games/${gameId}?dev=true`
|
||||
: `https://liluo.io/games/${gameId}`,
|
||||
getGameUrlWithSlug: (userSlug: string, gameSlug: string) =>
|
||||
isDev
|
||||
? `https://liluo.io/${userSlug.toLowerCase()}/${gameSlug.toLowerCase()}?dev=true`
|
||||
: `https://liluo.io/${userSlug.toLowerCase()}/${gameSlug.toLowerCase()}`,
|
||||
getUserPublicProfileUrl: (userId: string, username: ?string) =>
|
||||
username
|
||||
? `https://liluo.io/${username}${isDev ? '?dev=true' : ''}`
|
||||
: `https://liluo.io/user/${userId}${isDev ? '?dev=true' : ''}`,
|
||||
};
|
||||
|
||||
export const GDevelopBuildApi = {
|
||||
@@ -66,3 +76,9 @@ export const GDevelopUserApi = {
|
||||
? 'https://yrun9q6udj.execute-api.us-east-1.amazonaws.com/dev'
|
||||
: 'https://api.gdevelop-app.com/user',
|
||||
};
|
||||
|
||||
export const GDevelopPlayApi = {
|
||||
baseUrl: isDev
|
||||
? 'https://n9dsp0xfw6.execute-api.us-east-1.amazonaws.com/dev'
|
||||
: 'https://api.gdevelop-app.com/play',
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ import { type UserPublicProfileSearch } from './User';
|
||||
|
||||
export type ExampleShortHeader = {|
|
||||
id: string,
|
||||
slug: string,
|
||||
name: string,
|
||||
shortDescription: string,
|
||||
license: string,
|
||||
|
@@ -22,6 +22,9 @@ export type PublicGame = {
|
||||
cachedLastWeekSessionsCount?: number,
|
||||
cachedLastYearSessionsCount?: number,
|
||||
categories?: string[],
|
||||
userSlug?: string,
|
||||
gameSlug?: string,
|
||||
discoverable?: boolean,
|
||||
};
|
||||
|
||||
export type Game = {
|
||||
@@ -32,6 +35,13 @@ export type Game = {
|
||||
publicWebBuildId?: ?string,
|
||||
description?: string,
|
||||
thumbnailUrl?: string,
|
||||
discoverable?: boolean,
|
||||
};
|
||||
|
||||
export type GameSlug = {
|
||||
username: string,
|
||||
gameSlug: string,
|
||||
createdAt: number,
|
||||
};
|
||||
|
||||
export type ShowcasedGameLink = {
|
||||
@@ -67,6 +77,10 @@ export type AllShowcasedGames = {
|
||||
filters: Filters,
|
||||
};
|
||||
|
||||
export type GameApiError = {|
|
||||
code: 'game-deletion/leaderboards-exist',
|
||||
|};
|
||||
|
||||
export const allGameCategories = [
|
||||
'action',
|
||||
'adventure',
|
||||
@@ -125,9 +139,11 @@ export const getCategoryName = (category: string, i18n: I18nType) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getGameUrl = (game: ?Game) => {
|
||||
export const getGameUrl = (game: ?Game, slug: ?GameSlug) => {
|
||||
if (!game) return null;
|
||||
return GDevelopGamesPlatform.getGameUrl(game.id);
|
||||
return slug
|
||||
? GDevelopGamesPlatform.getGameUrlWithSlug(slug.username, slug.gameSlug)
|
||||
: GDevelopGamesPlatform.getGameUrl(game.id);
|
||||
};
|
||||
|
||||
export const getAclsFromUserIds = (
|
||||
@@ -205,6 +221,7 @@ export const updateGame = (
|
||||
playWithMobile,
|
||||
orientation,
|
||||
thumbnailUrl,
|
||||
discoverable,
|
||||
}: {|
|
||||
gameName?: string,
|
||||
categories?: string[],
|
||||
@@ -216,6 +233,7 @@ export const updateGame = (
|
||||
playWithMobile?: boolean,
|
||||
orientation?: string,
|
||||
thumbnailUrl?: ?string,
|
||||
discoverable?: boolean,
|
||||
|}
|
||||
): Promise<Game> => {
|
||||
return getAuthorizationHeader()
|
||||
@@ -233,6 +251,7 @@ export const updateGame = (
|
||||
playWithMobile,
|
||||
orientation,
|
||||
thumbnailUrl,
|
||||
discoverable,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
@@ -277,6 +296,35 @@ export const setGameUserAcls = (
|
||||
.then(response => response.data);
|
||||
};
|
||||
|
||||
export const setGameSlug = (
|
||||
getAuthorizationHeader: () => Promise<string>,
|
||||
userId: string,
|
||||
gameId: string,
|
||||
userSlug: string,
|
||||
gameSlug: string
|
||||
): Promise<void> => {
|
||||
return getAuthorizationHeader()
|
||||
.then(authorizationHeader =>
|
||||
axios.post(
|
||||
`${GDevelopGameApi.baseUrl}/game/action/set-slug`,
|
||||
{
|
||||
gameId,
|
||||
userSlug,
|
||||
gameSlug,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
userId,
|
||||
},
|
||||
headers: {
|
||||
Authorization: authorizationHeader,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
.then(response => response.data);
|
||||
};
|
||||
|
||||
export const getGame = (
|
||||
getAuthorizationHeader: () => Promise<string>,
|
||||
userId: string,
|
||||
@@ -338,3 +386,23 @@ export const getPublicGame = (gameId: string): Promise<PublicGame> => {
|
||||
.get(`${GDevelopGameApi.baseUrl}/public-game/${gameId}`)
|
||||
.then(response => response.data);
|
||||
};
|
||||
|
||||
export const getGameSlugs = (
|
||||
getAuthorizationHeader: () => Promise<string>,
|
||||
userId: string,
|
||||
gameId: string
|
||||
): Promise<Array<GameSlug>> => {
|
||||
return getAuthorizationHeader()
|
||||
.then(authorizationHeader =>
|
||||
axios.get(`${GDevelopGameApi.baseUrl}/game-slug`, {
|
||||
params: {
|
||||
userId,
|
||||
gameId,
|
||||
},
|
||||
headers: {
|
||||
Authorization: authorizationHeader,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(response => response.data);
|
||||
};
|
||||
|
258
newIDE/app/src/Utils/GDevelopServices/Play.js
Normal file
258
newIDE/app/src/Utils/GDevelopServices/Play.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// @flow
|
||||
import axios from 'axios';
|
||||
import { GDevelopPlayApi } from './ApiConfigs';
|
||||
|
||||
import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext';
|
||||
|
||||
export type LeaderboardSortOption = 'ASC' | 'DESC';
|
||||
export type LeaderboardVisibilityOption = 'HIDDEN' | 'PUBLIC';
|
||||
export type LeaderboardPlayerUnicityDisplayOption =
|
||||
| 'FREE'
|
||||
| 'PREFER_UNIQUE'
|
||||
| 'PREFER_NON_UNIQUE';
|
||||
|
||||
export type Leaderboard = {|
|
||||
id: string,
|
||||
gameId: string,
|
||||
name: string,
|
||||
sort: LeaderboardSortOption,
|
||||
startDatetime: string,
|
||||
deletedAt?: string,
|
||||
playerUnicityDisplayChoice: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility: LeaderboardVisibilityOption,
|
||||
|};
|
||||
|
||||
export type LeaderboardEntry = {|
|
||||
id: string,
|
||||
leaderboardId: string,
|
||||
playerId?: string,
|
||||
playerName: string,
|
||||
createdAt: string,
|
||||
score: number,
|
||||
deletedAt?: string,
|
||||
outdatedAt?: string,
|
||||
|};
|
||||
|
||||
export type LeaderboardDisplayData = {|
|
||||
+id: string,
|
||||
+playerName: string,
|
||||
+createdAt: string,
|
||||
+score: number,
|
||||
|};
|
||||
|
||||
export type LeaderboardExtremePlayerScore = {|
|
||||
leaderboardId: string,
|
||||
playerId?: string,
|
||||
playerName: string,
|
||||
relatedEntryCreatedAt: string,
|
||||
score: number,
|
||||
relatedEntryId: string,
|
||||
|};
|
||||
|
||||
export const extractEntryDisplayData = ({
|
||||
playerName,
|
||||
id,
|
||||
score,
|
||||
createdAt,
|
||||
}: LeaderboardEntry): LeaderboardDisplayData => ({
|
||||
id,
|
||||
createdAt,
|
||||
playerName,
|
||||
score,
|
||||
});
|
||||
|
||||
export const extractExtremeScoreDisplayData = ({
|
||||
playerName,
|
||||
relatedEntryId,
|
||||
score,
|
||||
relatedEntryCreatedAt,
|
||||
}: LeaderboardExtremePlayerScore): LeaderboardDisplayData => ({
|
||||
id: relatedEntryId,
|
||||
createdAt: relatedEntryCreatedAt,
|
||||
playerName,
|
||||
score,
|
||||
});
|
||||
|
||||
export const breakUuid = (uuid: string): string => `${uuid.split('-')[0]}-...`;
|
||||
|
||||
export const listGameLeaderboards = async (
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
gameId: string
|
||||
): Promise<?Array<Leaderboard>> => {
|
||||
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
const { uid: userId } = firebaseUser;
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
return axios
|
||||
.get(`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard`, {
|
||||
headers: { Authorization: authorizationHeader },
|
||||
params: { userId },
|
||||
})
|
||||
.then(response =>
|
||||
response.data.filter(leaderboard => !leaderboard.deletedAt)
|
||||
);
|
||||
};
|
||||
|
||||
export const extractNextPageUriFromLinkHeader = (
|
||||
linkHeader: string
|
||||
): ?string => {
|
||||
const links = linkHeader.split(',').map(link => link.trim());
|
||||
const mapRelationToUri = links.reduce((acc, link) => {
|
||||
const relationRegexMatch = link.match(/;\srel="(\w*)"/);
|
||||
const uriMatch = link.match(/^<(.*)>/);
|
||||
if (acc && relationRegexMatch && uriMatch) {
|
||||
acc[relationRegexMatch[1]] = uriMatch[1];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
if (Object.keys(mapRelationToUri).includes('next')) {
|
||||
return mapRelationToUri.next;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const listLeaderboardEntries = async (
|
||||
gameId: string,
|
||||
leaderboardId: string,
|
||||
options: {| pageSize: number, onlyBestEntry: boolean, forceUri: ?string |}
|
||||
): Promise<{|
|
||||
entries: LeaderboardEntry[] | LeaderboardExtremePlayerScore[],
|
||||
nextPageUri: ?string,
|
||||
|}> => {
|
||||
const uri =
|
||||
options.forceUri || `/game/${gameId}/leaderboard/${leaderboardId}/entry`;
|
||||
// $FlowFixMe
|
||||
const response = await axios.get(`${GDevelopPlayApi.baseUrl}${uri}`, {
|
||||
params: options.forceUri
|
||||
? null
|
||||
: {
|
||||
onlyBestEntry: options.onlyBestEntry,
|
||||
perPage: options.pageSize,
|
||||
},
|
||||
});
|
||||
const nextPageUri = response.headers.link
|
||||
? extractNextPageUriFromLinkHeader(response.headers.link)
|
||||
: null;
|
||||
return {
|
||||
entries: response.data,
|
||||
nextPageUri,
|
||||
};
|
||||
};
|
||||
|
||||
export const createLeaderboard = async (
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
gameId: string,
|
||||
{ name, sort }: {| name: string, sort: LeaderboardSortOption |}
|
||||
): Promise<?Leaderboard> => {
|
||||
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
const { uid: userId } = firebaseUser;
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
const response = await axios.post(
|
||||
`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard`,
|
||||
{
|
||||
name,
|
||||
sort,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: authorizationHeader },
|
||||
params: { userId },
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateLeaderboard = async (
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
gameId: string,
|
||||
leaderboardId: string,
|
||||
payload: {|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}
|
||||
): Promise<?Leaderboard> => {
|
||||
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
const { uid: userId } = firebaseUser;
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
const response = await axios.patch(
|
||||
`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard/${leaderboardId}`,
|
||||
payload,
|
||||
{
|
||||
headers: { Authorization: authorizationHeader },
|
||||
params: { userId },
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const resetLeaderboard = async (
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
gameId: string,
|
||||
leaderboardId: string
|
||||
): Promise<?Leaderboard> => {
|
||||
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
const { uid: userId } = firebaseUser;
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
const response = await axios.put(
|
||||
`${
|
||||
GDevelopPlayApi.baseUrl
|
||||
}/game/${gameId}/leaderboard/${leaderboardId}/reset`,
|
||||
{},
|
||||
{
|
||||
headers: { Authorization: authorizationHeader },
|
||||
params: { userId },
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteLeaderboard = async (
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
gameId: string,
|
||||
leaderboardId: string
|
||||
): Promise<?Leaderboard> => {
|
||||
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
const { uid: userId } = firebaseUser;
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
const response = await axios.delete(
|
||||
`${GDevelopPlayApi.baseUrl}/game/${gameId}/leaderboard/${leaderboardId}`,
|
||||
{
|
||||
headers: { Authorization: authorizationHeader },
|
||||
params: { userId },
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteLeaderboardEntry = async (
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
gameId: string,
|
||||
leaderboardId: string,
|
||||
entryId: string
|
||||
): Promise<?Leaderboard> => {
|
||||
const { getAuthorizationHeader, firebaseUser } = authenticatedUser;
|
||||
if (!firebaseUser) return;
|
||||
|
||||
const { uid: userId } = firebaseUser;
|
||||
const authorizationHeader = await getAuthorizationHeader();
|
||||
const response = await axios.delete(
|
||||
`${
|
||||
GDevelopPlayApi.baseUrl
|
||||
}/game/${gameId}/leaderboard/${leaderboardId}/entry/${entryId}`,
|
||||
{
|
||||
headers: { Authorization: authorizationHeader },
|
||||
params: { userId },
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
};
|
39
newIDE/app/src/Utils/GDevelopServices/Play.spec.js
Normal file
39
newIDE/app/src/Utils/GDevelopServices/Play.spec.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
|
||||
import { extractNextPageUriFromLinkHeader } from './Play';
|
||||
|
||||
describe('Play service', () => {
|
||||
describe('extractNextPageUriFromLinkHeader', () => {
|
||||
test('it returns null if input is empty', () => {
|
||||
expect(extractNextPageUriFromLinkHeader('')).toBeNull();
|
||||
});
|
||||
test('it returns null if input is not compliant', () => {
|
||||
expect(
|
||||
extractNextPageUriFromLinkHeader('https://www.liluo.io/ rel="next"')
|
||||
).toBeNull();
|
||||
});
|
||||
test('it returns null if next link is not present', () => {
|
||||
expect(
|
||||
extractNextPageUriFromLinkHeader(
|
||||
'<https://www.liluo.io/>; rel="prev", <https://www.liluo.io/>; rel="home"'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
test('it returns URI if next link is present', () => {
|
||||
expect(
|
||||
extractNextPageUriFromLinkHeader(
|
||||
'<https://www.liluo.io/>; rel="next", <https://www.liluo.io/>; rel="home"'
|
||||
)
|
||||
).toEqual('https://www.liluo.io/');
|
||||
});
|
||||
test('it returns URI with encoded query parameters', () => {
|
||||
expect(
|
||||
extractNextPageUriFromLinkHeader(
|
||||
'<https://www.liluo.io/game/3723963b-4f27-4896-9d62-32b1b0adddd5/leaderboard/aa7a8a96-dcf5-405c-8844-f133bcf223c7/entries?after=%7B%22GSIRawSK%22%3A1.89%2C%22parentId%22%3A%22LID%23aa7a8a96-dcf5-405c-8844-f133bcf223c7%22%2C%22childId%22%3A%22LE%23497f82ec-3aba-4ff0-a001-07d7c128f890%22%7D&perPage=10&onlyBestEntry=false>; rel="next", <https://www.liluo.io/>; rel="home"'
|
||||
)
|
||||
).toEqual(
|
||||
'https://www.liluo.io/game/3723963b-4f27-4896-9d62-32b1b0adddd5/leaderboard/aa7a8a96-dcf5-405c-8844-f133bcf223c7/entries?after=%7B%22GSIRawSK%22%3A1.89%2C%22parentId%22%3A%22LID%23aa7a8a96-dcf5-405c-8844-f133bcf223c7%22%2C%22childId%22%3A%22LE%23497f82ec-3aba-4ff0-a001-07d7c128f890%22%7D&perPage=10&onlyBestEntry=false'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@@ -731,6 +731,7 @@ export const game1: Game = {
|
||||
authorName: 'My company',
|
||||
gameName: 'My Great Game',
|
||||
createdAt: 1606065498,
|
||||
publicWebBuildId: 'fake-publicwebbuild-id',
|
||||
};
|
||||
|
||||
export const game2: Game = {
|
||||
@@ -818,6 +819,7 @@ export const showcasedGame1: ShowcasedGame = {
|
||||
|
||||
export const exampleFromFutureVersion: ExampleShortHeader = {
|
||||
id: 'fake-id',
|
||||
slug: 'fake-slug',
|
||||
name: 'Fake example',
|
||||
shortDescription: 'This is a fake example made in a future GDevelop version',
|
||||
license: 'MIT',
|
||||
@@ -829,6 +831,7 @@ export const exampleFromFutureVersion: ExampleShortHeader = {
|
||||
export const geometryMonsterExampleShortHeader: ExampleShortHeader = {
|
||||
id: '2ff24efa0de9b1340d7e8c8aedb494af6b4db9a72c6a643303734755efb977df',
|
||||
name: 'Geometry monster',
|
||||
slug: 'geometry-monster',
|
||||
shortDescription:
|
||||
'A hyper casual endless game where you have to collect shapes and avoid bombs, with a progressively increasing difficulty.\n',
|
||||
license: 'MIT',
|
||||
|
@@ -5,342 +5,342 @@ module.exports = [
|
||||
"languageCode": "af_ZA",
|
||||
"languageName": "Afrikaans",
|
||||
"languageNativeName": "Afrikaans",
|
||||
"translationRatio": 0.04930313588850177
|
||||
"translationRatio": 0.048422847399829494
|
||||
},
|
||||
{
|
||||
"languageCode": "ar_SA",
|
||||
"languageName": "Arabic",
|
||||
"languageNativeName": "اللغة العربية",
|
||||
"translationRatio": 0.39947735191637634
|
||||
"translationRatio": 0.3926683716965047
|
||||
},
|
||||
{
|
||||
"languageCode": "az_AZ",
|
||||
"languageName": "Azerbaijani",
|
||||
"languageNativeName": "azərbaycan dili",
|
||||
"translationRatio": 0.03466898954703834
|
||||
"translationRatio": 0.0341005967604433
|
||||
},
|
||||
{
|
||||
"languageCode": "bg_BG",
|
||||
"languageName": "Bulgarian",
|
||||
"languageNativeName": "български език",
|
||||
"translationRatio": 0.04982578397212545
|
||||
"translationRatio": 0.051832907075873846
|
||||
},
|
||||
{
|
||||
"languageCode": "bn_BD",
|
||||
"languageName": "Bengali",
|
||||
"languageNativeName": "বাংলা",
|
||||
"translationRatio": 0.05993031358885015
|
||||
"translationRatio": 0.05882352941176472
|
||||
},
|
||||
{
|
||||
"languageCode": "ca_ES",
|
||||
"languageName": "Catalan",
|
||||
"languageNativeName": "Català",
|
||||
"translationRatio": 0.33292682926829265
|
||||
"translationRatio": 0.3258312020460358
|
||||
},
|
||||
{
|
||||
"languageCode": "cs_CZ",
|
||||
"languageName": "Czech",
|
||||
"languageNativeName": "čeština",
|
||||
"translationRatio": 0.15226480836236933
|
||||
"translationRatio": 0.148849104859335
|
||||
},
|
||||
{
|
||||
"languageCode": "da_DK",
|
||||
"languageName": "Danish",
|
||||
"languageNativeName": "dansk",
|
||||
"translationRatio": 0.06916376306620209
|
||||
"translationRatio": 0.06786018755328216
|
||||
},
|
||||
{
|
||||
"languageCode": "de_DE",
|
||||
"languageName": "German",
|
||||
"languageNativeName": "Deutsch",
|
||||
"translationRatio": 0.7388501742160278
|
||||
"translationRatio": 0.7437340153452685
|
||||
},
|
||||
{
|
||||
"languageCode": "el_GR",
|
||||
"languageName": "Greek",
|
||||
"languageNativeName": "Ελληνικά",
|
||||
"translationRatio": 0.2602787456445993
|
||||
"translationRatio": 0.2878090366581415
|
||||
},
|
||||
{
|
||||
"languageCode": "en",
|
||||
"languageName": "English",
|
||||
"languageNativeName": "English",
|
||||
"translationRatio": 0.043771043771043794
|
||||
"translationRatio": 0.042389210019267876
|
||||
},
|
||||
{
|
||||
"languageCode": "eo_UY",
|
||||
"languageName": "Esperanto",
|
||||
"languageNativeName": "Esperanto",
|
||||
"translationRatio": 0.03519163763066202
|
||||
"translationRatio": 0.034612105711849916
|
||||
},
|
||||
{
|
||||
"languageCode": "es_ES",
|
||||
"languageName": "Spanish",
|
||||
"languageNativeName": "Español",
|
||||
"translationRatio": 0.9853658536585366
|
||||
"translationRatio": 0.9722080136402387
|
||||
},
|
||||
{
|
||||
"languageCode": "fa_IR",
|
||||
"languageName": "Persian",
|
||||
"languageNativeName": "فارسی",
|
||||
"translationRatio": 0.22560975609756095
|
||||
"translationRatio": 0.2208013640238704
|
||||
},
|
||||
{
|
||||
"languageCode": "fi_FI",
|
||||
"languageName": "Finnish",
|
||||
"languageNativeName": "suomi",
|
||||
"translationRatio": 0.2498257839721254
|
||||
"translationRatio": 0.24791133844842284
|
||||
},
|
||||
{
|
||||
"languageCode": "fil_PH",
|
||||
"languageName": "Filipino",
|
||||
"languageNativeName": "Mga Filipino",
|
||||
"translationRatio": 0.4139372822299652
|
||||
"translationRatio": 0.40528559249786866
|
||||
},
|
||||
{
|
||||
"languageCode": "fr_FR",
|
||||
"languageName": "French",
|
||||
"languageNativeName": "Français",
|
||||
"translationRatio": 0.9827526132404181
|
||||
"translationRatio": 0.9648763853367434
|
||||
},
|
||||
{
|
||||
"languageCode": "ha_HG",
|
||||
"languageName": "Hausa",
|
||||
"languageNativeName": "هَوُسَ",
|
||||
"translationRatio": 0.01724738675958193
|
||||
"translationRatio": 0.01705029838022165
|
||||
},
|
||||
{
|
||||
"languageCode": "he_IL",
|
||||
"languageName": "Hebrew",
|
||||
"languageNativeName": "עברית",
|
||||
"translationRatio": 0.06027874564459934
|
||||
"translationRatio": 0.059164535379369165
|
||||
},
|
||||
{
|
||||
"languageCode": "hi_IN",
|
||||
"languageName": "Hindi",
|
||||
"languageNativeName": "हिन्दी",
|
||||
"translationRatio": 0.2653310104529617
|
||||
"translationRatio": 0.259846547314578
|
||||
},
|
||||
{
|
||||
"languageCode": "hu_HU",
|
||||
"languageName": "Hungarian",
|
||||
"languageNativeName": "magyar",
|
||||
"translationRatio": 0.2139372822299651
|
||||
"translationRatio": 0.20937766410912195
|
||||
},
|
||||
{
|
||||
"languageCode": "id_ID",
|
||||
"languageName": "Indonesian",
|
||||
"languageNativeName": "Bahasa Indonesia",
|
||||
"translationRatio": 0.5665505226480836
|
||||
"translationRatio": 0.5549872122762148
|
||||
},
|
||||
{
|
||||
"languageCode": "ig_NG",
|
||||
"languageName": "Igbo",
|
||||
"languageNativeName": "Asụsụ Igbo",
|
||||
"translationRatio": 0.011324041811846652
|
||||
"translationRatio": 0.011253196930946285
|
||||
},
|
||||
{
|
||||
"languageCode": "it_IT",
|
||||
"languageName": "Italian",
|
||||
"languageNativeName": "Italiano",
|
||||
"translationRatio": 0.8973867595818815
|
||||
"translationRatio": 0.8782608695652174
|
||||
},
|
||||
{
|
||||
"languageCode": "ja_JP",
|
||||
"languageName": "Japanese",
|
||||
"languageNativeName": "日本語",
|
||||
"translationRatio": 0.9909407665505227
|
||||
"translationRatio": 0.9754475703324809
|
||||
},
|
||||
{
|
||||
"languageCode": "ka_GE",
|
||||
"languageName": "Georgian",
|
||||
"languageNativeName": "ქართული",
|
||||
"translationRatio": 0.011324041811846652
|
||||
"translationRatio": 0.011253196930946285
|
||||
},
|
||||
{
|
||||
"languageCode": "km_KH",
|
||||
"languageName": "Khmer",
|
||||
"languageNativeName": "ខេមរភាសា",
|
||||
"translationRatio": 0.017944250871080092
|
||||
"translationRatio": 0.017732310315430544
|
||||
},
|
||||
{
|
||||
"languageCode": "ko_KR",
|
||||
"languageName": "Korean",
|
||||
"languageNativeName": "한국어",
|
||||
"translationRatio": 0.6656794425087108
|
||||
"translationRatio": 0.6514919011082694
|
||||
},
|
||||
{
|
||||
"languageCode": "lt_LT",
|
||||
"languageName": "Lithuanian",
|
||||
"languageNativeName": "lietuvių kalba",
|
||||
"translationRatio": 0.04529616724738672
|
||||
"translationRatio": 0.04450127877237853
|
||||
},
|
||||
{
|
||||
"languageCode": "lv_LV",
|
||||
"languageName": "Latvian",
|
||||
"languageNativeName": "latviešu valoda",
|
||||
"translationRatio": 0.011324041811846652
|
||||
"translationRatio": 0.011253196930946285
|
||||
},
|
||||
{
|
||||
"languageCode": "mr_IN",
|
||||
"languageName": "Marathi",
|
||||
"languageNativeName": "मराठी",
|
||||
"translationRatio": 0.04303135888501741
|
||||
"translationRatio": 0.04228473998294968
|
||||
},
|
||||
{
|
||||
"languageCode": "ms_MY",
|
||||
"languageName": "Malay",
|
||||
"languageNativeName": "Bahasa Malaysia",
|
||||
"translationRatio": 0.04233449477351914
|
||||
"translationRatio": 0.04160272804774079
|
||||
},
|
||||
{
|
||||
"languageCode": "my_MM",
|
||||
"languageName": "Burmese",
|
||||
"languageNativeName": "ဗမာစာ",
|
||||
"translationRatio": 0.012020905923344927
|
||||
"translationRatio": 0.011935208866155178
|
||||
},
|
||||
{
|
||||
"languageCode": "nl_NL",
|
||||
"languageName": "Dutch",
|
||||
"languageNativeName": "Nederlands",
|
||||
"translationRatio": 0.3395470383275261
|
||||
"translationRatio": 0.33350383631713554
|
||||
},
|
||||
{
|
||||
"languageCode": "no_NO",
|
||||
"languageName": "Norwegian",
|
||||
"languageNativeName": "Norsk",
|
||||
"translationRatio": 0.1759581881533101
|
||||
"translationRatio": 0.17220801364023874
|
||||
},
|
||||
{
|
||||
"languageCode": "pl_PL",
|
||||
"languageName": "Polish",
|
||||
"languageNativeName": "język polski",
|
||||
"translationRatio": 0.7965156794425087
|
||||
"translationRatio": 0.7793691389599318
|
||||
},
|
||||
{
|
||||
"languageCode": "pseudo_LOCALE",
|
||||
"languageName": "for development only",
|
||||
"languageNativeName": "Pseudolocalization",
|
||||
"translationRatio": 0.9959595959595959
|
||||
"translationRatio": 0.9961464354527938
|
||||
},
|
||||
{
|
||||
"languageCode": "pt_BR",
|
||||
"languageName": "Brazilian Portuguese",
|
||||
"languageNativeName": "Português brasileiro",
|
||||
"translationRatio": 0.9560975609756097
|
||||
"translationRatio": 0.9536231884057971
|
||||
},
|
||||
{
|
||||
"languageCode": "pt_PT",
|
||||
"languageName": "Portuguese",
|
||||
"languageNativeName": "Português",
|
||||
"translationRatio": 0.47108013937282234
|
||||
"translationRatio": 0.4610400682011935
|
||||
},
|
||||
{
|
||||
"languageCode": "ro_RO",
|
||||
"languageName": "Romanian",
|
||||
"languageNativeName": "Română",
|
||||
"translationRatio": 0.06829268292682922
|
||||
"translationRatio": 0.07672634271099743
|
||||
},
|
||||
{
|
||||
"languageCode": "ru_RU",
|
||||
"languageName": "Russian",
|
||||
"languageNativeName": "Русский",
|
||||
"translationRatio": 0.9787456445993031
|
||||
"translationRatio": 0.9568627450980393
|
||||
},
|
||||
{
|
||||
"languageCode": "si_LK",
|
||||
"languageName": "Sinhala",
|
||||
"languageNativeName": "සිංහල",
|
||||
"translationRatio": 0.01184668989547033
|
||||
"translationRatio": 0.0117647058823529
|
||||
},
|
||||
{
|
||||
"languageCode": "sk_SK",
|
||||
"languageName": "Slovak",
|
||||
"languageNativeName": "slovenčina",
|
||||
"translationRatio": 0.24912891986062713
|
||||
"translationRatio": 0.24586530264279627
|
||||
},
|
||||
{
|
||||
"languageCode": "sl_SI",
|
||||
"languageName": "Slovene",
|
||||
"languageNativeName": "slovenski jezik",
|
||||
"translationRatio": 0.9925087108013937
|
||||
"translationRatio": 0.9759590792838875
|
||||
},
|
||||
{
|
||||
"languageCode": "sq_AL",
|
||||
"languageName": "Albanian",
|
||||
"languageNativeName": "Shqip",
|
||||
"translationRatio": 0.12613240418118465
|
||||
"translationRatio": 0.13930093776641095
|
||||
},
|
||||
{
|
||||
"languageCode": "sr_CS",
|
||||
"languageName": "Serbian (Latin)",
|
||||
"languageNativeName": "srpski",
|
||||
"translationRatio": 0.23101045296167244
|
||||
"translationRatio": 0.22608695652173916
|
||||
},
|
||||
{
|
||||
"languageCode": "sr_SP",
|
||||
"languageName": "Serbian",
|
||||
"languageNativeName": "српски језик",
|
||||
"translationRatio": 0.47839721254355405
|
||||
"translationRatio": 0.4682011935208866
|
||||
},
|
||||
{
|
||||
"languageCode": "sv_SE",
|
||||
"languageName": "Swedish",
|
||||
"languageNativeName": "svenska",
|
||||
"translationRatio": 0.20522648083623696
|
||||
"translationRatio": 0.2010230179028133
|
||||
},
|
||||
{
|
||||
"languageCode": "th_TH",
|
||||
"languageName": "Thai",
|
||||
"languageNativeName": "ไทย",
|
||||
"translationRatio": 0.049128919860627174
|
||||
"translationRatio": 0.048252344416027326
|
||||
},
|
||||
{
|
||||
"languageCode": "tr_TR",
|
||||
"languageName": "Turkish",
|
||||
"languageNativeName": "Türkçe",
|
||||
"translationRatio": 0.8048780487804879
|
||||
"translationRatio": 0.8032395566922421
|
||||
},
|
||||
{
|
||||
"languageCode": "uk_UA",
|
||||
"languageName": "Ukrainian",
|
||||
"languageNativeName": "Українська",
|
||||
"translationRatio": 0.7954703832752613
|
||||
"translationRatio": 0.8185848252344416
|
||||
},
|
||||
{
|
||||
"languageCode": "ur_PK",
|
||||
"languageName": "Urdu",
|
||||
"languageNativeName": "اردو",
|
||||
"translationRatio": 0.019686411149825833
|
||||
"translationRatio": 0.019437340153452665
|
||||
},
|
||||
{
|
||||
"languageCode": "uz_UZ",
|
||||
"languageName": "Uzbek",
|
||||
"languageNativeName": "Ўзбек",
|
||||
"translationRatio": 0.01898954703832756
|
||||
"translationRatio": 0.018755328218243772
|
||||
},
|
||||
{
|
||||
"languageCode": "vi_VN",
|
||||
"languageName": "Vietnamese",
|
||||
"languageNativeName": "Tiếng Việt",
|
||||
"translationRatio": 0.04965156794425085
|
||||
"translationRatio": 0.04876385336743394
|
||||
},
|
||||
{
|
||||
"languageCode": "yo_NG",
|
||||
"languageName": "Yoruba",
|
||||
"languageNativeName": "Yorùbá",
|
||||
"translationRatio": 0.44808362369337984
|
||||
"translationRatio": 0.4387041773231032
|
||||
},
|
||||
{
|
||||
"languageCode": "zh_CN",
|
||||
"languageName": "Chinese Simplified",
|
||||
"languageNativeName": "简化字",
|
||||
"translationRatio": 0.9925087108013937
|
||||
"translationRatio": 0.9769820971867008
|
||||
},
|
||||
{
|
||||
"languageCode": "zh_TW",
|
||||
"languageName": "Chinese Traditional",
|
||||
"languageNativeName": "正體字",
|
||||
"translationRatio": 0.28135888501742157
|
||||
"translationRatio": 0.27519181585677754
|
||||
}
|
||||
];
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user