Compare commits

...

38 Commits

Author SHA1 Message Date
Davy Hélard
e36d6018cb Typo 2022-04-15 21:19:36 +02:00
Davy Hélard
c0665dbba0 Hide advanced parameters and properties from beginners. 2022-04-15 20:54:09 +02:00
github-actions[bot]
860e9d36e4 Update translations [skip ci] (#3808)
Co-authored-by: fannieyan <fannieyan@users.noreply.github.com>
2022-04-14 11:40:38 +02:00
Fannie Yan
c8b461cc5f Merge game management buttons in games dashboard (#3826)
* Merge game management button into one in Games dashboard
2022-04-14 10:51:48 +02:00
AlexandreS
36cdc5720a Bump newIDE version 2022-04-14 10:47:33 +02:00
AlexandreS
a7cd53b921 Add built-in leaderboard feature (actions, conditions, administration) (#3759) 2022-04-14 10:25:48 +02:00
D8H
01a25400ff New touch event functions that allow to get them several times (#3820)
* It allows extensions to handle touch events without conflicting with the scene event sheets.
* Existing touch functions continue to work and can be used alongside new ones without any issue.
2022-04-13 12:00:33 +02:00
D8H
2b484c0cf1 The url slug of a game can be verified and changed right before publication (#3811) 2022-04-12 20:39:34 +02:00
Fannie Yan
13204e4b53 Add a button "Access public profile" in one's profile details (#3819)
Add a button in profile details that opens the user's public profile on Liluo.io
2022-04-11 14:41:08 +02:00
D8H
c6d6466d54 Fix the input checkboxes "Playable with [...]" randomly checked when publishing a game for the first time (#3818) 2022-04-08 11:45:20 +01:00
Clément Pasteau
6cf737ac70 Remove unused imports (#3809)
Do not show in changelog
2022-04-06 15:00:04 +02:00
Arthur Pacaud
5b8cdbbace Update the button label in the dialog of an extension to show if the extension will be updated or (re)installed (#3777) 2022-04-06 13:47:36 +01:00
Clément Pasteau
a7df37a0dc Bump version to 131 (#3807) 2022-04-06 14:47:20 +02:00
D8H
7721f56dde Allow to specify a slug to be used in liluo.io games URL
* From the Game Details Dashboard, you can now select a username and a game name to create a custom unique URL for your game on Liluo.io, which can look like this: liluo.io/your-username/your-game-name
2022-04-06 14:35:50 +02:00
github-actions[bot]
08ada4f595 Update translations [skip ci] (#3764) 2022-04-06 13:21:28 +01:00
D8H
caa46a2fb0 Allow to define a default value for a "string with options" property (#3803) 2022-04-06 09:49:50 +02:00
D8H
660cdd753c [TopDownMovement] Add a condition to check which key was used or simulated and actions to change the current velocity (#3800) 2022-04-05 19:32:31 +02:00
D8H
3b9a1354ee [TopDownMovement] Fix the velocity value that wasn't exactly 0 when moving horizontally or vertically (#3799) 2022-04-04 23:47:17 +02:00
D8H
02fce4132a Add a condition to check if the mouse is inside the window. (#3787) 2022-04-01 12:01:53 +02:00
D8H
41241d75e1 Fix the camera rounding to work when following objects with even dimensions and pixel perfect mode is enabled (#3789) 2022-04-01 12:00:47 +02:00
Clément Pasteau
fc3e73f360 Rework the Game publishing on Liluo
* Display a summary of the Game that will be published when pushing a new build
* Add a helper message to change the game thumbnail
* Add option to save project by default when publishing to Liluo.io
* Allow defining if your game should be discoverable on Liluo.io or not
2022-04-01 10:50:05 +02:00
Florian Rival
230493039c Display an icon for the Home tab (instead of the label "Home") (#3792) 2022-04-01 09:40:22 +01:00
Florian Rival
50326e4d21 Add missing Text Input to the web-app (#3785)
Don't show in changelog
2022-03-29 13:17:44 +02:00
Florian Rival
cec7960a7d Add slug to ExampleShortHeader and Example type
Don't show in changelog
2022-03-28 16:22:49 +02:00
Florian Rival
cec901d4c2 Fix npm not installing git:// dependencies (#3780)
Don't show in changelog
2022-03-28 14:55:01 +02:00
Florian Rival
29f0567140 Add Linux zip and deb target to the packaged desktop app (#3776)
Only show in developer changelog
2022-03-27 12:55:50 +02:00
Clément Pasteau
94af33c815 Switch game metrics from live ones to cached ones for performance
Do not show in changelog
2022-03-25 14:19:14 +01:00
Florian Rival
3ad7585f92 Add a linter rule to avoid importing prop-types
Don't show in changelog
2022-03-24 11:48:07 +01:00
AlexandreS
acb69c447d Improve events sheet search with new shortcuts and put back the "Edit variable" row in variable editor 2022-03-23 15:40:57 +01:00
Elairyx
fa928b35cb Add autocompletion for the "Simulate control" action of the Top Down movement behavior (#3767) 2022-03-21 23:15:02 +01:00
Florian Rival
67810b79ce Refactor some redundant code in Text Input tests (#3768)
Don't show in changelog
2022-03-21 23:08:54 +01:00
Clément Pasteau
0a77e0264d Bump version to 5.0.130 2022-03-21 11:53:52 +01:00
Clément Pasteau
4cc8d15f4b Fix invalid game thumbnail URL 2022-03-21 11:42:36 +01:00
Clément Pasteau
a2f0b3384e Bump newIDE version to 5.0.129 2022-03-17 14:57:32 +01:00
github-actions[bot]
432ffebf80 Update translations [skip ci] (#3695)
Co-authored-by: Bouh <Bouh@users.noreply.github.com>
2022-03-17 14:40:24 +01:00
Aurélien Vivet
165470d9c1 Add D as shortcut to toggle disabled event, also added in right click context menu in the event sheet (#3751) 2022-03-17 11:35:38 +01:00
D8H
53eab0ed62 Fix the "Is moving" condition of platformer characters by removing the 1 pixel per frame detection limit (#3730)
This can be important for games with low resolution.
2022-03-16 19:09:14 +01:00
Florian Rival
649a36ac30 Fix text input wrongly staying visible even if hidden or layer visibility turned off (#3755) 2022-03-16 18:56:11 +01:00
194 changed files with 7297 additions and 1678 deletions

View File

@@ -135,7 +135,7 @@ jobs:
# Build GDevelop IDE (seems like we need to allow Node.js to use more space than usual)
- run:
name: Build GDevelop IDE
command: export NODE_OPTIONS="--max-old-space-size=7168" && cd newIDE/electron-app && npm run build -- --linux AppImage --publish=never
command: export NODE_OPTIONS="--max-old-space-size=7168" && cd newIDE/electron-app && npm run build -- --linux AppImage zip deb --publish=never
- run:
name: Clean dist folder to keep only installers/binaries.

View File

@@ -839,6 +839,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
"edges, but are not overlapping (default: no)"),
"",
true)
.MarkParameterAsAdvanced()
.SetDefaultValue("no")
.MarkAsSimple();

View File

@@ -40,6 +40,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0")
.MarkAsAdvanced();
@@ -62,6 +63,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0")
.MarkAsAdvanced();
@@ -116,6 +118,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0")
.MarkAsAdvanced();
@@ -234,6 +237,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0");
// TODO Deprecated: hide this action in a future release.
@@ -262,10 +266,12 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
_("Anticipate the movement of the object (yes by default)"),
"",
true)
.MarkParameterAsAdvanced()
.SetDefaultValue("yes")
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0")
.MarkAsAdvanced();
@@ -291,6 +297,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0")
.MarkAsAdvanced();
@@ -309,10 +316,12 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsCameraExtension(
_("Anticipate the movement of the object (yes by default)"),
"",
true)
.MarkParameterAsAdvanced()
.SetDefaultValue("yes")
.AddParameter("layer", _("Layer (base layer if empty)"), "", true)
.SetDefaultValue("\"\"")
.AddParameter("expression", _("Camera number (default : 0)"), "", true)
.MarkParameterAsAdvanced()
.SetDefaultValue("0")
.MarkAsSimple();

View File

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

View File

@@ -202,6 +202,18 @@ class GD_CORE_API InstructionMetadata {
return *this;
};
/**
* \brief Consider that the parameter is harder for a user to understand
* and not necessary for common usages.
*
* \see AddParameter
*/
InstructionMetadata &MarkParameterAsAdvanced() {
if (!parameters.empty())
parameters.back().MarkAsAdvanced();
return *this;
};
/**
* \brief Add the default parameters for an instruction manipulating the
* specified type ("string", "number") with the default operators.

View File

@@ -91,6 +91,18 @@ class GD_CORE_API MultipleInstructionMetadata {
return *this;
};
/**
* \brief Consider that the parameter is harder for a user to understand
* and not necessary for common usages.
*
* \see AddParameter
*/
MultipleInstructionMetadata &MarkParameterAsAdvanced() {
if (condition) condition->MarkParameterAsAdvanced();
if (action) action->MarkParameterAsAdvanced();
return *this;
};
/**
* \see gd::InstructionMetadata::SetHidden
*/

View File

@@ -10,7 +10,7 @@
namespace gd {
ParameterMetadata::ParameterMetadata() : optional(false), codeOnly(false) {}
ParameterMetadata::ParameterMetadata() : optional(false), codeOnly(false), usageComplexity(5) {}
void ParameterMetadata::SerializeTo(SerializerElement& element) const {
element.SetAttribute("type", type);
@@ -21,6 +21,7 @@ void ParameterMetadata::SerializeTo(SerializerElement& element) const {
element.SetAttribute("codeOnly", codeOnly);
element.SetAttribute("defaultValue", defaultValue);
element.SetAttribute("name", name);
element.SetAttribute("usageComplexity", usageComplexity);
}
void ParameterMetadata::UnserializeFrom(const SerializerElement& element) {
@@ -33,6 +34,7 @@ void ParameterMetadata::UnserializeFrom(const SerializerElement& element) {
codeOnly = element.GetBoolAttribute("codeOnly");
defaultValue = element.GetStringAttribute("defaultValue");
name = element.GetStringAttribute("name");
usageComplexity = element.GetIntAttribute("usageComplexity");
}
} // namespace gd

View File

@@ -9,6 +9,7 @@
#if defined(GD_IDE_ONLY)
#include <map>
#include <memory>
#include "GDCore/String.h"
namespace gd {
class Project;
@@ -150,6 +151,37 @@ class GD_CORE_API ParameterMetadata {
return *this;
}
/**
* \brief Consider that the parameter is easy for a user to understand.
*/
ParameterMetadata &MarkAsSimple() {
usageComplexity = 2;
return *this;
}
/**
* \brief Consider that the parameter is harder for a user to understand
* than a normal parameter.
*/
ParameterMetadata &MarkAsAdvanced() {
usageComplexity = 7;
return *this;
}
/**
* \brief Consider that the parameter is complex for a user to understand.
*/
ParameterMetadata &MarkAsComplex() {
usageComplexity = 9;
return *this;
}
/**
* \brief Return the usage complexity of this parameter for the user,
* from 0 (simple&easy to use) to 10 (complex to understand).
*/
int GetUsageComplexity() const { return usageComplexity; }
/**
* \brief Return true if the type of the parameter is "object", "objectPtr" or
* "objectList".
@@ -196,7 +228,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";
@@ -233,6 +266,8 @@ class GD_CORE_API ParameterMetadata {
///< optional parameter is empty.
gd::String name; ///< The name of the parameter to be used in code
///< generation. Optional.
int usageComplexity; ///< Evaluate the parameter from 0 (simple&easy to
///< use) to 10 (complex to understand)
};
} // namespace gd

View File

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

View File

@@ -27,6 +27,7 @@ void PropertyDescriptor::SerializeTo(SerializerElement& element) const {
extraInformationElement.AddChild("").SetStringValue(information);
}
element.AddChild("hidden").SetBoolValue(hidden);
element.AddChild("usageComplexity").SetIntValue(usageComplexity);
}
void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) {
@@ -47,6 +48,7 @@ void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) {
hidden = element.HasChild("hidden")
? element.GetChild("hidden").GetBoolValue()
: false;
usageComplexity = element.GetChild("usageComplexity").GetIntValue();
}
void PropertyDescriptor::SerializeValuesTo(SerializerElement& element) const {

View File

@@ -28,12 +28,12 @@ class GD_CORE_API PropertyDescriptor {
* \param propertyValue The value of the property.
*/
PropertyDescriptor(gd::String propertyValue)
: currentValue(propertyValue), type("string"), label(""), hidden(false) {}
: currentValue(propertyValue), type("string"), label(""), hidden(false), usageComplexity(5) {}
/**
* \brief Empty constructor creating an empty property to be displayed.
*/
PropertyDescriptor() : hidden(false){};
PropertyDescriptor() : hidden(false), usageComplexity(5) {};
/**
* \brief Destructor
@@ -104,6 +104,37 @@ class GD_CORE_API PropertyDescriptor {
return *this;
}
/**
* \brief Consider that the property is easy for a user to understand.
*/
PropertyDescriptor &MarkAsSimple() {
usageComplexity = 2;
return *this;
}
/**
* \brief Consider that the property is harder for a user to understand
* than a normal property.
*/
PropertyDescriptor &MarkAsAdvanced() {
usageComplexity = 7;
return *this;
}
/**
* \brief Consider that the property is complex for a user to understand.
*/
PropertyDescriptor &MarkAsComplex() {
usageComplexity = 9;
return *this;
}
/**
* \brief Return the usage complexity of this property for the user,
* from 0 (simple&easy to use) to 10 (complex to understand).
*/
int GetUsageComplexity() const { return usageComplexity; }
const gd::String& GetValue() const { return currentValue; }
const gd::String& GetType() const { return type; }
const gd::String& GetLabel() const { return label; }
@@ -168,6 +199,8 @@ class GD_CORE_API PropertyDescriptor {
///< choices, if a property is a displayed as a combo
///< box.
bool hidden;
int usageComplexity; ///< Evaluate the parameter from 0 (simple&easy to
///< use) to 10 (complex to understand)
};
} // namespace gd

View File

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

View 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 [];
},
};

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

File diff suppressed because one or more lines are too long

View 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(
'aιΥÉᚱÀeThatsTooonToBeՏaѵÊĐThisPartAppears'
)
).to.be('aEAeThatsTooonToBeaEThisPartAp');
});
});
});

View File

@@ -43,6 +43,7 @@ void DeclarePlatformBehaviorExtension(gd::PlatformExtension& extension) {
std::make_shared<PlatformerObjectBehavior>(),
std::make_shared<gd::BehaviorsSharedData>());
// Deprecated, use IsMovingEvenALittle instead
aut.AddCondition("IsMoving",
_("Is moving"),
_("Check if the object is moving (whether it is on the "
@@ -53,9 +54,22 @@ void DeclarePlatformBehaviorExtension(gd::PlatformExtension& extension) {
"CppPlatform/Extensions/platformerobjecticon.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "PlatformerObjectBehavior")
.SetHidden()
.MarkAsSimple()
.SetFunctionName("IsMoving");
aut.AddScopedCondition("IsMovingEvenALittle",
_("Is moving"),
_("Check if the object is moving (whether it is on the "
"floor or in the air)."),
_("_PARAM0_ is moving"),
"",
"CppPlatform/Extensions/platformerobjecticon.png",
"CppPlatform/Extensions/platformerobjecticon.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "PlatformerObjectBehavior")
.MarkAsSimple();
aut.AddCondition("IsOnFloor",
_("Is on floor"),
_("Check if the object is on a platform."),
@@ -523,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",

View File

@@ -53,6 +53,8 @@ class PlatformBehaviorJsExtension : public gd::PlatformExtension {
"PlatformBehavior::PlatformerObjectBehavior");
autConditions["PlatformBehavior::IsMoving"].SetFunctionName("isMoving");
autConditions["PlatformBehavior::PlatformerObjectBehavior::IsMovingEvenALittle"]
.SetFunctionName("isMovingEvenALittle");
autConditions["PlatformBehavior::IsOnFloor"].SetFunctionName("isOnFloor");
autConditions["PlatformBehavior::IsOnLadder"].SetFunctionName(
"isOnLadder");

View File

@@ -88,7 +88,8 @@ PlatformerObjectBehavior::GetProperties(
.SetValue(behaviorContent.GetBoolAttribute("canGrabWithoutMoving", false)
? "true"
: "false")
.SetType("Boolean");
.SetType("Boolean")
.MarkAsAdvanced();
properties[_("Grab offset on Y axis")]
.SetGroup(_("Ledge"))
.SetValue(
@@ -104,13 +105,15 @@ PlatformerObjectBehavior::GetProperties(
.SetValue(behaviorContent.GetBoolAttribute("useLegacyTrajectory", true)
? "true"
: "false")
.SetType("Boolean");
.SetType("Boolean")
.MarkAsComplex();
properties[_("Can go down from jumpthru platforms")]
.SetGroup(_("Walk"))
.SetValue(behaviorContent.GetBoolAttribute("canGoDownFromJumpthru", false)
? "true"
: "false")
.SetType("Boolean");
.SetType("Boolean")
.MarkAsAdvanced();
return properties;
}

View File

@@ -24,6 +24,11 @@ namespace gdjs {
isCollidingAnyPlatform: false,
};
/**
* A very small value compare to 1 pixel, yet very huge compare to rounding errors.
*/
private static readonly epsilon = 2 ** -20;
// Behavior configuration
/** To achieve pixel-perfect precision when positioning object on platform or
@@ -107,6 +112,8 @@ namespace gdjs {
_overlappedJumpThru: Array<gdjs.PlatformRuntimeBehavior>;
private _hasReallyMoved: boolean = false;
/** @deprecated use _hasReallyMoved instead */
private _hasMovedAtLeastOnePixel: boolean = false;
private _manager: gdjs.PlatformObjectsManager;
constructor(
@@ -312,6 +319,11 @@ namespace gdjs {
//5) Track the movement
this._hasReallyMoved =
Math.abs(object.getX() - oldX) >
PlatformerObjectRuntimeBehavior.epsilon ||
Math.abs(object.getY() - oldY) >
PlatformerObjectRuntimeBehavior.epsilon;
this._hasMovedAtLeastOnePixel =
Math.abs(object.getX() - oldX) >= 1 ||
Math.abs(object.getY() - oldY) >= 1;
this._lastDeltaY = object.getY() - oldY;
@@ -1543,9 +1555,27 @@ namespace gdjs {
/**
* Check if the Platformer Object is moving.
*
* When walking or climbing on a ladder,
* a speed of less than one pixel per frame won't be detected.
*
* @returns Returns true if it is moving and false if not.
* @deprecated use isMovingEvenALittle instead
*/
isMoving(): boolean {
return (
(this._hasMovedAtLeastOnePixel &&
(this._currentSpeed !== 0 || this._state === this._onLadder)) ||
this._jumping.getCurrentJumpSpeed() !== 0 ||
this._currentFallSpeed !== 0
);
}
/**
* Check if the Platformer Object is moving.
* @returns Returns true if it is moving and false if not.
*/
isMovingEvenALittle(): boolean {
return (
(this._hasReallyMoved &&
(this._currentSpeed !== 0 || this._state === this._onLadder)) ||

View File

@@ -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(' ', {

View File

@@ -58,7 +58,7 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
});
};
it('creates the DOM element', async () => {
const setupObjectAndGetDomElementContainer = async () => {
const runtimeGame = await gdjs.getPixiRuntimeGameWithAssets();
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
loadScene(runtimeScene);
@@ -70,10 +70,6 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
const object = makeTextInputRuntimeObject(runtimeScene);
runtimeScene.addObject(object);
// Check the default size.
expect(object.getWidth()).to.be(300);
expect(object.getHeight()).to.be(30);
// Check that the DOM element was created
const gameDomElementContainer = runtimeGame
.getRenderer()
@@ -83,6 +79,20 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
'Expected getDomElementContainer to return a valid container.'
);
return { runtimeScene, gameDomElementContainer, object };
};
it('creates the DOM element', async () => {
const {
runtimeScene,
gameDomElementContainer,
object,
} = await setupObjectAndGetDomElementContainer();
// Check the default size.
expect(object.getWidth()).to.be(300);
expect(object.getHeight()).to.be(30);
expect(gameDomElementContainer.hasChildNodes()).to.be(true);
const inputElement = gameDomElementContainer.querySelector('input');
@@ -98,25 +108,11 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
});
it('destroys the DOM element when the scene is paused/resumed/stopped', async () => {
const runtimeGame = await gdjs.getPixiRuntimeGameWithAssets();
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
loadScene(runtimeScene);
// Make sure the renderer is created (to test the real DOM element creation/update)
const gameContainer = document.createElement('div');
runtimeGame.getRenderer().createStandardCanvas(gameContainer);
const object = makeTextInputRuntimeObject(runtimeScene);
runtimeScene.addObject(object);
// Check that the DOM element was created
const gameDomElementContainer = runtimeGame
.getRenderer()
.getDomElementContainer();
if (!gameDomElementContainer)
throw new Error(
'Expected getDomElementContainer to return a valid container.'
);
const {
runtimeScene,
gameDomElementContainer,
object,
} = await setupObjectAndGetDomElementContainer();
expect(gameDomElementContainer.querySelector('input')).not.to.be(null);
@@ -129,28 +125,17 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
runtimeScene.unloadScene();
expect(gameDomElementContainer.querySelector('input')).to.be(null);
// Clean up - not mandatory but to avoid overloading the testing browser.
runtimeScene.unloadScene();
});
it('changes the DOM element when the object type is updated', async () => {
const runtimeGame = await gdjs.getPixiRuntimeGameWithAssets();
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
loadScene(runtimeScene);
// Make sure the renderer is created (to test the real DOM element creation/update)
const gameContainer = document.createElement('div');
runtimeGame.getRenderer().createStandardCanvas(gameContainer);
const object = makeTextInputRuntimeObject(runtimeScene);
runtimeScene.addObject(object);
// Check that the DOM element was created
const gameDomElementContainer = runtimeGame
.getRenderer()
.getDomElementContainer();
if (!gameDomElementContainer)
throw new Error(
'Expected getDomElementContainer to return a valid container.'
);
const {
runtimeScene,
gameDomElementContainer,
object,
} = await setupObjectAndGetDomElementContainer();
expect(gameDomElementContainer.querySelector('input')).not.to.be(null);
@@ -169,4 +154,80 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
// Clean up - not mandatory but to avoid overloading the testing browser.
runtimeScene.unloadScene();
});
it('hides the DOM element when the object or layer is hidden', async () => {
const {
runtimeScene,
gameDomElementContainer,
object,
} = await setupObjectAndGetDomElementContainer();
const inputElement = gameDomElementContainer.querySelector('input');
if (!inputElement) throw new Error('Expected input element to be found');
// Check visibility of the DOM element is visible by default, if it should be visible
// on the screen.
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('initial');
// Check visibility of the DOM element is updated at each frame,
// according to the object visibility.
object.hide(true);
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('none');
object.hide(false);
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('initial');
// Check visibility of the DOM element is updated at each frame,
// according to the layer visibility.
runtimeScene.getLayer('').show(false);
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('none');
runtimeScene.getLayer('').show(true);
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('initial');
// Clean up - not mandatory but to avoid overloading the testing browser.
runtimeScene.unloadScene();
});
it('hides the DOM element when the object is far from the camera', async () => {
const {
runtimeScene,
gameDomElementContainer,
object,
} = await setupObjectAndGetDomElementContainer();
const inputElement = gameDomElementContainer.querySelector('input');
if (!inputElement) throw new Error('Expected input element to be found');
// Check visibility of the DOM element is visible by default, if it should be visible
// on the screen.
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('initial');
// Check visibility of the DOM element is updated at each frame,
// according to the object position of screen.
object.setX(-500); // -500 + 300 (object default width) = -200, still outside the camera.
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('none');
object.setWidth(600); // -500 + 600 = 100, inside the camera
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('initial');
runtimeScene.getLayer('').setCameraX(900);
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('none');
runtimeScene.getLayer('').setCameraX(400);
runtimeScene.renderAndStep(1000 / 60);
expect(inputElement.style.display).to.be('initial');
// Clean up - not mandatory but to avoid overloading the testing browser.
runtimeScene.unloadScene();
});
});

View File

@@ -111,7 +111,23 @@ namespace gdjs {
updatePreRender() {
if (!this._input) return;
// Hide the input entirely if the object is hidden.
// Because this object is rendered as a DOM element (and not part of the PixiJS
// scene graph), we have to do this manually.
if (this._object.isHidden()) {
this._input.style.display = 'none';
return;
}
// Hide the input entirely if the layer is not visible.
// Because this object is rendered as a DOM element (and not part of the PixiJS
// scene graph), we have to do this manually.
const layer = this._runtimeScene.getLayer(this._object.getLayer());
if (!layer.isVisible()) {
this._input.style.display = 'none';
return;
}
const runtimeGame = this._runtimeScene.getGame();
const runtimeGameRenderer = runtimeGame.getRenderer();
const topLeftCanvasCoordinates = layer.convertInverseCoords(
@@ -125,7 +141,7 @@ namespace gdjs {
0
);
// Hide the input entirely if not visible at all
// Hide the input entirely if not visible at all.
const isOutsideCanvas =
bottomRightCanvasCoordinates[0] < 0 ||
bottomRightCanvasCoordinates[1] < 0 ||
@@ -136,7 +152,7 @@ namespace gdjs {
return;
}
// Position the input on the container on top of the canvas
// Position the input on the container on top of the canvas.
const topLeftPageCoordinates = runtimeGameRenderer.convertCanvasToDomElementContainerCoords(
topLeftCanvasCoordinates
);

View File

@@ -105,7 +105,9 @@ void DeclareTopDownMovementBehaviorExtension(gd::PlatformExtension& extension) {
"res/conditions/keyboard.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "TopDownMovementBehavior")
.AddParameter("string", _("Key"))
.AddParameter("stringWithSelector",
_("Key"),
"[\"Left\", \"Right\", \"Up\", \"Down\"]")
.MarkAsAdvanced()
.SetFunctionName("SimulateControl")
.SetIncludeFile(
@@ -143,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."),
@@ -530,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"),

View File

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

View File

@@ -87,20 +87,23 @@ TopDownMovementBehavior::GetProperties(
.AddExtraInfo(_("Top-Down"))
.AddExtraInfo(_("Isometry 2:1 (26.565°)"))
.AddExtraInfo(_("True Isometry (30°)"))
.AddExtraInfo(_("Custom Isometry"));
.AddExtraInfo(_("Custom Isometry"))
.MarkAsAdvanced();
properties[_("Custom isometry angle")]
.SetGroup(_("Viewpoint"))
.SetValue(gd::String::From(
behaviorContent.GetDoubleAttribute("customIsometryAngle")))
.SetDescription(_("If you choose \"Custom Isometry\", this allows to "
"specify the angle of your isometry projection."));
"specify the angle of your isometry projection."))
.MarkAsAdvanced();
properties[_("Movement angle offset")]
.SetGroup(_("Viewpoint"))
.SetValue(gd::String::From(
behaviorContent.GetDoubleAttribute("movementAngleOffset")))
.SetDescription(_(
"Usually 0, unless you choose an *Isometry* viewpoint in which case "
"-45 is recommended."));
"-45 is recommended."))
.MarkAsAdvanced();
return properties;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
/**

View File

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

View File

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

View File

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

View File

@@ -681,11 +681,16 @@ namespace gdjs {
this._runtimeGame
.getEffectsManager()
.updatePreRender(object.getRendererEffects(), object);
}
}
// Perform pre-render update.
object.updatePreRender(this);
// Perform pre-render update only if the object is visible
// (including if there is no visibility AABB returned previously).
object.updatePreRender(this);
}
} else {
// Perform pre-render update, always for objects not having an
// associated renderer object (so it must handle visibility on its own).
object.updatePreRender(this);
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -773,6 +773,11 @@ interface PropertyDescriptor {
[Ref] VectorString GetExtraInfo();
[Ref] PropertyDescriptor SetHidden(boolean enable);
boolean IsHidden();
long GetUsageComplexity();
[Ref] PropertyDescriptor MarkAsSimple();
[Ref] PropertyDescriptor MarkAsAdvanced();
[Ref] PropertyDescriptor MarkAsComplex();
void SerializeTo([Ref] SerializerElement element);
void UnserializeFrom([Const, Ref] SerializerElement element);
@@ -1138,6 +1143,7 @@ interface InstructionMetadata {
[Ref] InstructionMetadata SetDefaultValue([Const] DOMString defaultValue);
[Ref] InstructionMetadata SetParameterLongDescription([Const] DOMString longDescription);
[Ref] InstructionMetadata SetParameterExtraInfo([Const] DOMString extraInfo);
[Ref] InstructionMetadata MarkParameterAsAdvanced();
[Ref] InstructionMetadata UseStandardOperatorParameters([Const] DOMString type);
[Ref] InstructionMetadata UseStandardRelationalOperatorParameters([Const] DOMString type);
@@ -1200,6 +1206,7 @@ interface MultipleInstructionMetadata {
[Const] DOMString type, [Const] DOMString supplementaryInformation);
[Ref] MultipleInstructionMetadata SetDefaultValue([Const] DOMString defaultValue);
[Ref] MultipleInstructionMetadata SetParameterLongDescription([Const] DOMString longDescription);
[Ref] MultipleInstructionMetadata MarkParameterAsAdvanced();
[Ref] MultipleInstructionMetadata UseStandardParameters([Const] DOMString type);
@@ -1255,6 +1262,10 @@ interface ParameterMetadata {
[Ref] ParameterMetadata SetCodeOnly(boolean codeOnly_);
[Const, Ref] DOMString GetDefaultValue();
[Ref] ParameterMetadata SetDefaultValue([Const] DOMString defaultValue_);
[Ref] ParameterMetadata MarkAsSimple();
[Ref] ParameterMetadata MarkAsAdvanced();
[Ref] ParameterMetadata MarkAsComplex();
long GetUsageComplexity();
boolean STATIC_IsObject([Const] DOMString param);
boolean STATIC_IsBehavior([Const] DOMString param);

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ declare class gdInstructionMetadata {
setDefaultValue(defaultValue: string): gdInstructionMetadata;
setParameterLongDescription(longDescription: string): gdInstructionMetadata;
setParameterExtraInfo(extraInfo: string): gdInstructionMetadata;
markParameterAsAdvanced(): gdInstructionMetadata;
useStandardOperatorParameters(type: string): gdInstructionMetadata;
useStandardRelationalOperatorParameters(type: string): gdInstructionMetadata;
setRequiresBaseObjectCapability(capability: string): gdInstructionMetadata;

View File

@@ -4,6 +4,7 @@ declare class gdMultipleInstructionMetadata {
addCodeOnlyParameter(type: string, supplementaryInformation: string): gdMultipleInstructionMetadata;
setDefaultValue(defaultValue: string): gdMultipleInstructionMetadata;
setParameterLongDescription(longDescription: string): gdMultipleInstructionMetadata;
markParameterAsAdvanced(): gdMultipleInstructionMetadata;
useStandardParameters(type: string): gdMultipleInstructionMetadata;
setHidden(): gdMultipleInstructionMetadata;
setFunctionName(functionName: string): gdMultipleInstructionMetadata;

View File

@@ -17,6 +17,10 @@ declare class gdParameterMetadata {
setCodeOnly(codeOnly_: boolean): gdParameterMetadata;
getDefaultValue(): string;
setDefaultValue(defaultValue_: string): gdParameterMetadata;
markAsSimple(): gdParameterMetadata;
markAsAdvanced(): gdParameterMetadata;
markAsComplex(): gdParameterMetadata;
getUsageComplexity(): number;
static isObject(param: string): boolean;
static isBehavior(param: string): boolean;
serializeTo(element: gdSerializerElement): void;

View File

@@ -16,6 +16,10 @@ declare class gdPropertyDescriptor {
getExtraInfo(): gdVectorString;
setHidden(enable: boolean): gdPropertyDescriptor;
isHidden(): boolean;
getUsageComplexity(): number;
markAsSimple(): gdPropertyDescriptor;
markAsAdvanced(): gdPropertyDescriptor;
markAsComplex(): gdPropertyDescriptor;
serializeTo(element: gdSerializerElement): void;
unserializeFrom(element: gdSerializerElement): void;
serializeValuesTo(element: gdSerializerElement): void;

View File

@@ -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",
@@ -36774,7 +36774,8 @@
"node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"devOptional": true
},
"node_modules/minipass": {
"version": "3.1.3",
@@ -41755,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",
@@ -54910,7 +54911,8 @@
},
"@lingui/react": {
"version": "git+ssh://git@github.com/4ian/lingui-react.git#dc6b1e013470d952cf85f96cc4affdd28e29634a",
"from": "@lingui/react@git://github.com/4ian/lingui-react.git#master",
"integrity": "sha512-eoYJ8TI+8IolPh4fue9aIwX2OVp0YrPnV86QBZLfGhxknodVeNmx+4Ic4ym7rI5/davbk9AUZHcssiH+YZWVxw==",
"from": "@lingui/react@github:4ian/lingui-react#master",
"requires": {
"@lingui/core": "2.7.3",
"babel-runtime": "^6.26.0",
@@ -79076,7 +79078,8 @@
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
"devOptional": true
},
"minipass": {
"version": "3.1.3",
@@ -80354,7 +80357,8 @@
},
"pixi-simple-gesture": {
"version": "git+ssh://git@github.com/4ian/pixi-simple-gesture.git#c84e0cc3c62edeca019e708d9897ef6b97a0d18a",
"from": "pixi-simple-gesture@git://github.com/4ian/pixi-simple-gesture#v0.3.3"
"integrity": "sha512-DG1BxP8SK2iPMYWMOPGz5gKDXFmA8JPUpcyyNyIH55fpQraenuLYlosYFFMTRXEy0RZViTUu11H3VrYlfG2CgA==",
"from": "pixi-simple-gesture@github:4ian/pixi-simple-gesture#v0.3.3"
},
"pixi.js": {
"version": "6.1.2",
@@ -83072,7 +83076,8 @@
},
"react-mosaic-component": {
"version": "git+ssh://git@github.com/4ian/react-mosaic.git#d5ef155119d786c08c7c72e34997dcef2f01f98b",
"from": "react-mosaic-component@git://github.com/4ian/react-mosaic#v3.1.0",
"integrity": "sha512-Izfw/EkG1g39nrZbOqzY52rqFkVFA1SUSv1TLwk7soS1Wy7iHm6zrUgzJdfwKRC2GaDn9WAfSe5ZQ2vIJ/mu5A==",
"from": "react-mosaic-component@github:4ian/react-mosaic#v3.1.0",
"requires": {
"classnames": "^2.2.6",
"immutability-helper": "^3.0.1",
@@ -83086,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=="
}
}
},

View File

@@ -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",
@@ -122,6 +122,10 @@
"Trans"
],
"message": "Please import Trans from @lingui/macro"
},
{
"name": "prop-types",
"message": "Don't import anything from prop-types - use Flow/TypeScript types instead."
}
]
}

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@ export type CommandName =
| 'ADD_STANDARD_EVENT'
| 'ADD_SUBEVENT'
| 'ADD_COMMENT_EVENT'
| 'TOGGLE_EVENT_DISABLED'
| 'CHOOSE_AND_ADD_EVENT'
| 'EVENTS_EDITOR_UNDO'
| 'EVENTS_EDITOR_REDO'
@@ -279,6 +280,10 @@ const commandsList: { [CommandName]: CommandMetadata } = {
displayText: t`Add a sub-event to the selected event`,
},
ADD_COMMENT_EVENT: { area: 'EVENTS', displayText: t`Add a comment` },
TOGGLE_EVENT_DISABLED: {
area: 'EVENTS',
displayText: t`Toggle disabled event`,
},
CHOOSE_AND_ADD_EVENT: {
area: 'EVENTS',
displayText: t`Choose and add an event...`,

View File

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

View File

@@ -10,6 +10,7 @@ import { mapFor } from '../../Utils/MapFor';
import EmptyMessage from '../../UI/EmptyMessage';
import ParameterRenderingService from '../ParameterRenderingService';
import HelpButton from '../../UI/HelpButton';
import FlatButton from '../../UI/FlatButton';
import {
type ResourceSource,
type ChooseResourceFunction,
@@ -32,6 +33,7 @@ import { ColumnStackLayout } from '../../UI/Layout';
import { setupInstructionParameters } from '../../InstructionOrExpression/SetupInstructionParameters';
import ScrollView from '../../UI/ScrollView';
import { getInstructionTutorialIds } from '../../Utils/GDevelopServices/Tutorial';
import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext';
const gd: libGDevelop = global.gd;
const styles = {
@@ -80,6 +82,7 @@ type Props = {|
|};
type State = {|
isDirty: boolean,
showAdvancedParameter: boolean,
|};
const isParameterVisible = (
@@ -100,9 +103,13 @@ export default class InstructionParametersEditor extends React.Component<
Props,
State
> {
static contextType = PreferencesContext;
_firstVisibleField: ?any = {};
state = {
isDirty: false,
showAdvancedParameter: this.context.values
.showAdvancedParametersAndProperties,
};
componentDidMount() {
@@ -233,6 +240,9 @@ export default class InstructionParametersEditor extends React.Component<
objectName
);
const preferences = this.context;
let anyAdvancedParameterIsHidden = false;
let parameterFieldIndex = 0;
return (
<I18n>
@@ -306,6 +316,15 @@ export default class InstructionParametersEditor extends React.Component<
)
return null;
if (
!this.state.showAdvancedParameter &&
parameterMetadata.isOptional() &&
parameterMetadata.getUsageComplexity() > 5
) {
anyAdvancedParameterIsHidden = true;
return null;
}
const parameterMetadataType = parameterMetadata.getType();
const ParameterComponent = ParameterRenderingService.getParameterComponent(
parameterMetadataType
@@ -351,6 +370,28 @@ export default class InstructionParametersEditor extends React.Component<
/>
);
})}
{!this.state.showAdvancedParameter &&
anyAdvancedParameterIsHidden && (
<FlatButton
label={<Trans>Show advanced parameters</Trans>}
onClick={() =>
this.setState({
showAdvancedParameter: true,
})
}
/>
)}
{this.state.showAdvancedParameter &&
!preferences.values.showAdvancedParametersAndProperties && (
<FlatButton
label={<Trans>Always show advanced parameters</Trans>}
onClick={() =>
preferences.setShowAdvancedParametersAndProperties(
true
)
}
/>
)}
</ColumnStackLayout>
{this._getVisibleParametersCount(
instructionMetadata,

View File

@@ -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. */
}

View 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} />;

View File

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

View File

@@ -18,6 +18,7 @@ import {
import RaisedButton from '../UI/RaisedButton';
import { ColumnStackLayout } from '../UI/Layout';
import {
shouldBrowsePrevious,
shouldCloseOrCancel,
shouldValidate,
} from '../UI/KeyboardShortcuts/InteractionKeys';
@@ -37,6 +38,7 @@ type Props = {|
export type SearchPanelInterface = {|
focus: () => void,
markSearchResultsDirty: () => void,
isSearchOngoing: () => boolean,
|};
const SearchPanel = (
@@ -54,19 +56,6 @@ const SearchPanel = (
) => {
const searchTextField = React.useRef<?TextField>(null);
const focusSearchField = React.useCallback((): void => {
if (searchTextField.current) searchTextField.current.focus();
}, []);
const markSearchResultsDirty = React.useCallback((): void => {
setSearchResultsDirty(true);
}, []);
React.useImperativeHandle(ref, () => ({
focus: focusSearchField,
markSearchResultsDirty,
}));
const [searchText, setSearchText] = React.useState<string>('');
const [replaceText, setReplaceText] = React.useState<string>('');
const [matchCase, setMatchCase] = React.useState<boolean>(false);
@@ -89,6 +78,27 @@ const SearchPanel = (
'search-and-replace' | 'search-in-event-sentences'
>('search-and-replace');
const isSearchOngoing = React.useCallback(
(): boolean => {
return !!searchText && !searchResultsDirty;
},
[searchText, searchResultsDirty]
);
const focusSearchField = React.useCallback((): void => {
if (searchTextField.current) searchTextField.current.focus();
}, []);
const markSearchResultsDirty = React.useCallback((): void => {
setSearchResultsDirty(true);
}, []);
React.useImperativeHandle(ref, () => ({
isSearchOngoing,
focus: focusSearchField,
markSearchResultsDirty,
}));
React.useEffect(
() => {
setSearchResultsDirty(true);
@@ -171,7 +181,9 @@ const SearchPanel = (
setSearchText(searchText);
}}
onKeyPress={event => {
if (shouldValidate(event)) {
if (shouldBrowsePrevious(event)) {
onGoToPreviousSearchResult();
} else if (shouldValidate(event)) {
if (!searchResultsDirty) {
onGoToNextSearchResult();
} else {

View File

@@ -15,6 +15,8 @@ type Props = {|
onAddCommentEvent: () => void,
allEventsMetadata: Array<EventMetadata>,
onAddEvent: (eventType: string) => Array<gdBaseEvent>,
onToggleDisabledEvent: () => void,
canToggleEventDisabled: boolean,
onRemove: () => void,
canRemove: boolean,
undo: () => void,

View File

@@ -13,6 +13,8 @@ type Props = {|
onAddCommentEvent: () => void,
allEventsMetadata: Array<EventMetadata>,
onAddEvent: (eventType: string) => Array<gdBaseEvent>,
onToggleDisabledEvent: () => void,
canToggleEventDisabled: boolean,
onRemove: () => void,
canRemove: boolean,
undo: () => void,
@@ -38,6 +40,10 @@ const ToolbarCommands = (props: Props) => {
handler: props.onAddCommentEvent,
});
useCommand('TOGGLE_EVENT_DISABLED', props.canToggleEventDisabled, {
handler: props.onToggleDisabledEvent,
});
useCommandWithOptions('CHOOSE_AND_ADD_EVENT', true, {
generateOptions: React.useCallback(
() =>

View File

@@ -13,6 +13,7 @@ import EventTextDialog, {
} from './InstructionEditor/EventTextDialog';
import Toolbar from './Toolbar';
import KeyboardShortcuts from '../UI/KeyboardShortcuts';
import { getShortcutDisplayName } from '../KeyboardShortcuts';
import InlineParameterEditor from './InlineParameterEditor';
import ContextMenu, { type ContextMenuInterface } from '../UI/Menu/ContextMenu';
import { serializeToJSObject } from '../Utils/Serializer';
@@ -91,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 };
@@ -123,6 +127,7 @@ type ComponentProps = {|
...Props,
authenticatedUser: AuthenticatedUser,
preferences: Preferences,
leaderboardsManager: ?LeaderboardState,
|};
type State = {|
@@ -289,8 +294,10 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
onAddStandardEvent={this._addStandardEvent}
onAddSubEvent={this.addSubEvents}
canAddSubEvent={hasEventSelected(this.state.selection)}
canToggleEventDisabled={hasEventSelected(this.state.selection)}
onAddCommentEvent={this._addCommentEvent}
onAddEvent={this.addNewEvent}
onToggleDisabledEvent={this.toggleDisabled}
canRemove={hasSomethingSelected(this.state.selection)}
onRemove={this.deleteSelection}
canUndo={canUndo(this.state.history)}
@@ -314,6 +321,14 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
_toggleSearchPanel = () => {
this.setState(
state => {
if (
state.showSearchPanel &&
this._searchPanel &&
this._searchPanel.isSearchOngoing()
) {
this._searchPanel.focus();
return;
}
const show = !state.showSearchPanel;
if (!show) {
if (this._eventSearcher) this._eventSearcher.reset();
@@ -638,6 +653,11 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
label: i18n._(t`Toggle disabled`),
click: () => this.toggleDisabled(),
enabled: this._selectionCanToggleDisabled(),
accelerator: getShortcutDisplayName(
this.props.preferences.values.userShortcutMap[
'TOGGLE_EVENT_DISABLED'
] || 'KeyD'
),
},
{ type: 'separator' },
{
@@ -831,12 +851,18 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
};
toggleDisabled = () => {
getSelectedEvents(this.state.selection).forEach(event =>
event.setDisabled(!event.isDisabled())
);
this._saveChangesToHistory(() => {
if (this._eventsTree) this._eventsTree.forceEventsUpdate();
let shouldBeSaved = false;
getSelectedEvents(this.state.selection).forEach(event => {
if (event.isExecutable()) {
event.setDisabled(!event.isDisabled());
shouldBeSaved = true;
}
});
if (shouldBeSaved) {
this._saveChangesToHistory(() => {
if (this._eventsTree) this._eventsTree.forceEventsUpdate();
});
}
};
deleteSelection = ({
@@ -1541,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}
/>
);

View File

@@ -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}
/>
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
),

View 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",
"Ъ": "",
"ъ": "",
"Ь": "",
"ь": ""
}

View File

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

View File

@@ -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}
@@ -504,10 +622,8 @@ export const GameDetailsDialog = ({
<Trans>Last week sessions count</Trans>
</TableRowColumn>
<TableRowColumn style={styles.tableRowStatColumn}>
{publicGame &&
publicGame.metrics &&
publicGame.metrics.lastWeekSessionsCount
? publicGame.metrics.lastWeekSessionsCount
{publicGame && publicGame.cachedLastWeekSessionsCount
? publicGame.cachedLastWeekSessionsCount
: '-'}
</TableRowColumn>
</TableRow>
@@ -516,10 +632,8 @@ export const GameDetailsDialog = ({
<Trans>Last year sessions count</Trans>
</TableRowColumn>
<TableRowColumn style={styles.tableRowStatColumn}>
{publicGame &&
publicGame.metrics &&
publicGame.metrics.lastYearSessionsCount
? publicGame.metrics.lastYearSessionsCount
{publicGame && publicGame.cachedLastYearSessionsCount
? publicGame.cachedLastYearSessionsCount
: '-'}
</TableRowColumn>
</TableRow>
@@ -648,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>

View 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>
);

View File

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

View File

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

View 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;

View 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;

View File

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

View File

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

View File

@@ -51,6 +51,7 @@ const defaultShortcuts: ShortcutMap = {
ADD_STANDARD_EVENT: 'Shift+KeyA',
ADD_SUBEVENT: 'Shift+KeyD',
ADD_COMMENT_EVENT: '',
TOGGLE_EVENT_DISABLED: 'KeyD',
CHOOSE_AND_ADD_EVENT: 'Shift+KeyW',
OPEN_EXTENSION_SETTINGS: '',
};

View 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;

View 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;

View 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;

View File

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

View File

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

View File

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

View File

@@ -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>,
@@ -188,6 +193,7 @@ export type PreferencesValues = {|
isAlwaysOnTopInPreview: boolean,
backdropClickBehavior: 'nothing' | 'apply' | 'cancel',
eventsSheetCancelInlineParameter: 'cancel' | 'apply',
showAdvancedParametersAndProperties: boolean,
|};
/**
@@ -246,6 +252,8 @@ export type Preferences = {|
getIsAlwaysOnTopInPreview: () => boolean,
setIsAlwaysOnTopInPreview: (enabled: boolean) => void,
setEventsSheetCancelInlineParameter: (value: string) => void,
getShowAdvancedParametersAndProperties: () => boolean,
setShowAdvancedParametersAndProperties: (enabled: boolean) => void,
|};
export const initialPreferences = {
@@ -281,6 +289,7 @@ export const initialPreferences = {
isAlwaysOnTopInPreview: false,
backdropClickBehavior: 'nothing',
eventsSheetCancelInlineParameter: 'apply',
showAdvancedParametersAndProperties: false,
},
setLanguage: () => {},
setThemeName: () => {},
@@ -329,6 +338,8 @@ export const initialPreferences = {
getIsAlwaysOnTopInPreview: () => true,
setIsAlwaysOnTopInPreview: () => {},
setEventsSheetCancelInlineParameter: () => {},
getShowAdvancedParametersAndProperties: () => true,
setShowAdvancedParametersAndProperties: (enabled: boolean) => {},
};
const PreferencesContext = React.createContext<Preferences>(initialPreferences);

View File

@@ -44,6 +44,7 @@ const PreferencesDialog = ({ i18n, onClose }: Props) => {
setAutosaveOnPreview,
setUseNewInstructionEditorDialog,
setUseUndefinedVariablesInAutocompletion,
setShowAdvancedParametersAndProperties,
setUseGDJSDevelopmentWatcher,
setEventsSheetUseAssignmentOperators,
getDefaultEditorMosaicNode,
@@ -274,6 +275,18 @@ const PreferencesDialog = ({ i18n, onClose }: Props) => {
}
/>
</Line>
<Line>
<Toggle
onToggle={(e, check) =>
setShowAdvancedParametersAndProperties(check)
}
toggled={values.showAdvancedParametersAndProperties}
labelPosition="right"
label={
<Trans>Always show advanced parameters and properties</Trans>
}
/>
</Line>
<Line>
<SelectField
floatingLabelText={

View File

@@ -103,6 +103,12 @@ export default class PreferencesProvider extends React.Component<Props, State> {
setUseUndefinedVariablesInAutocompletion: this._setUseUndefinedVariablesInAutocompletion.bind(
this
),
getShowAdvancedParametersAndProperties: this._getShowAdvancedParametersAndProperties.bind(
this
),
setShowAdvancedParametersAndProperties: this._setShowAdvancedParametersAndProperties.bind(
this
),
setUseGDJSDevelopmentWatcher: this._setUseGDJSDevelopmentWatcher.bind(this),
setEventsSheetUseAssignmentOperators: this._setEventsSheetUseAssignmentOperators.bind(
this
@@ -205,6 +211,26 @@ export default class PreferencesProvider extends React.Component<Props, State> {
);
}
_getShowAdvancedParametersAndProperties(
showAdvancedParametersAndProperties: boolean
) {
return this.state.values.showAdvancedParametersAndProperties;
}
_setShowAdvancedParametersAndProperties(
showAdvancedParametersAndProperties: boolean
) {
this.setState(
state => ({
values: {
...state.values,
showAdvancedParametersAndProperties,
},
}),
() => this._persistValuesToLocalStorage(this.state)
);
}
_setUseGDJSDevelopmentWatcher(useGDJSDevelopmentWatcher: boolean) {
this.setState(
state => ({

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ function InstalledExtensionDetails({
<I18n>
{({ i18n }) => (
<ExtensionInstallDialog
alreadyInstalled
project={project}
isInstalling={isInstalling}
onClose={onClose}
onInstall={() => installOrUpdateExtension(i18n)}

View File

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

Some files were not shown because too many files have changed in this diff Show More