mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
38 Commits
add_disabl
...
feature/hi
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e36d6018cb | ||
![]() |
c0665dbba0 | ||
![]() |
860e9d36e4 | ||
![]() |
c8b461cc5f | ||
![]() |
36cdc5720a | ||
![]() |
a7cd53b921 | ||
![]() |
01a25400ff | ||
![]() |
2b484c0cf1 | ||
![]() |
13204e4b53 | ||
![]() |
c6d6466d54 | ||
![]() |
6cf737ac70 | ||
![]() |
5b8cdbbace | ||
![]() |
a7df37a0dc | ||
![]() |
7721f56dde | ||
![]() |
08ada4f595 | ||
![]() |
caa46a2fb0 | ||
![]() |
660cdd753c | ||
![]() |
3b9a1354ee | ||
![]() |
02fce4132a | ||
![]() |
41241d75e1 | ||
![]() |
fc3e73f360 | ||
![]() |
230493039c | ||
![]() |
50326e4d21 | ||
![]() |
cec7960a7d | ||
![]() |
cec901d4c2 | ||
![]() |
29f0567140 | ||
![]() |
94af33c815 | ||
![]() |
3ad7585f92 | ||
![]() |
acb69c447d | ||
![]() |
fa928b35cb | ||
![]() |
67810b79ce | ||
![]() |
0a77e0264d | ||
![]() |
4cc8d15f4b | ||
![]() |
a2f0b3384e | ||
![]() |
432ffebf80 | ||
![]() |
165470d9c1 | ||
![]() |
53eab0ed62 | ||
![]() |
649a36ac30 |
@@ -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.
|
||||
|
@@ -839,6 +839,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
|
||||
"edges, but are not overlapping (default: no)"),
|
||||
"",
|
||||
true)
|
||||
.MarkParameterAsAdvanced()
|
||||
.SetDefaultValue("no")
|
||||
.MarkAsSimple();
|
||||
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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.
|
||||
|
@@ -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
|
||||
*/
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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()),
|
||||
|
@@ -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 {
|
||||
|
@@ -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
|
||||
|
@@ -1,39 +1,40 @@
|
||||
describe('Inventory', function () {
|
||||
var runtimeGame = new gdjs.RuntimeGame({
|
||||
variables: [],
|
||||
properties: { windowWidth: 800, windowHeight: 600 },
|
||||
resources: { resources: [] },
|
||||
it('Inventories can be serialized then unserialized with no data loss', () => {
|
||||
var runtimeGame = new gdjs.RuntimeGame({
|
||||
variables: [],
|
||||
properties: { windowWidth: 800, windowHeight: 600 },
|
||||
resources: { resources: [] },
|
||||
});
|
||||
var runtimeScene = new gdjs.RuntimeScene(runtimeGame);
|
||||
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.equip(runtimeScene, 'MyInventory', 'sword', true);
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'armor');
|
||||
gdjs.evtTools.inventory.setMaximum(runtimeScene, 'MyInventory', 'armor', 1);
|
||||
|
||||
var variable = new gdjs.Variable();
|
||||
gdjs.evtTools.inventory.serializeToVariable(
|
||||
runtimeScene,
|
||||
'MyInventory',
|
||||
variable
|
||||
);
|
||||
gdjs.evtTools.inventory.unserializeFromVariable(
|
||||
runtimeScene,
|
||||
'MyInventory2',
|
||||
variable
|
||||
);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(2);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.isEquipped(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(true);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(1);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(false);
|
||||
});
|
||||
var runtimeScene = new gdjs.RuntimeScene(runtimeGame);
|
||||
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'sword');
|
||||
gdjs.evtTools.inventory.equip(runtimeScene, 'MyInventory', 'sword', true);
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory', 'armor');
|
||||
gdjs.evtTools.inventory.setMaximum(runtimeScene, 'MyInventory', 'armor', 1);
|
||||
|
||||
var variable = new gdjs.Variable();
|
||||
gdjs.evtTools.inventory.serializeToVariable(
|
||||
runtimeScene,
|
||||
'MyInventory',
|
||||
variable
|
||||
);
|
||||
gdjs.evtTools.inventory.unserializeFromVariable(
|
||||
runtimeScene,
|
||||
'MyInventory2',
|
||||
variable
|
||||
);
|
||||
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(2);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.isEquipped(runtimeScene, 'MyInventory2', 'sword')
|
||||
).to.be(true);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.count(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(1);
|
||||
expect(
|
||||
gdjs.evtTools.inventory.add(runtimeScene, 'MyInventory2', 'armor')
|
||||
).to.be(false);
|
||||
});
|
||||
|
272
Extensions/Leaderboards/JsExtension.js
Normal file
272
Extensions/Leaderboards/JsExtension.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// @flow
|
||||
/**
|
||||
* This is a declaration of an extension for GDevelop 5.
|
||||
*
|
||||
* ℹ️ Changes in this file are watched and automatically imported if the editor
|
||||
* is running. You can also manually run `node import-GDJS-Runtime.js` (in newIDE/app/scripts).
|
||||
*
|
||||
* The file must be named "JsExtension.js", otherwise GDevelop won't load it.
|
||||
* ⚠️ If you make a change and the extension is not loaded, open the developer console
|
||||
* and search for any errors.
|
||||
*
|
||||
* More information on https://github.com/4ian/GDevelop/blob/master/newIDE/README-extensions.md
|
||||
*/
|
||||
|
||||
/*::
|
||||
// Import types to allow Flow to do static type checking on this file.
|
||||
// Extensions declaration are typed using Flow (like the editor), but the files
|
||||
// for the game engine are checked with TypeScript annotations.
|
||||
import { type ObjectsRenderingService, type ObjectsEditorService } from '../JsExtensionTypes.flow.js'
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
createExtension: function (
|
||||
_ /*: (string) => string */,
|
||||
gd /*: libGDevelop */
|
||||
) {
|
||||
const extension = new gd.PlatformExtension();
|
||||
extension
|
||||
.setExtensionInformation(
|
||||
'Leaderboards',
|
||||
_('Leaderboards (experimental)'),
|
||||
_('Allow your game to send scores to your leaderboards.'),
|
||||
'Florian Rival',
|
||||
'Open source (MIT License)'
|
||||
)
|
||||
.setExtensionHelpPath('/all-features/leaderboards')
|
||||
.setCategory('Leaderboards')
|
||||
.addInstructionOrExpressionGroupMetadata(_('Leaderboards'))
|
||||
.setIcon('JsPlatform/Extensions/leaderboard.svg');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'SavePlayerScore',
|
||||
_('Save player score'),
|
||||
_("Save the player's score to the given leaderboard."),
|
||||
_(
|
||||
'Send to leaderboard _PARAM1_ the score _PARAM2_ with player name: _PARAM3_.'
|
||||
),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', false)
|
||||
.addParameter(
|
||||
'expression',
|
||||
_('Score to register for the player'),
|
||||
'',
|
||||
false
|
||||
)
|
||||
.addParameter('string', _('Name to register for the player'), '', false)
|
||||
.addParameter(
|
||||
'scenevar',
|
||||
_('Variable where to store the saved score (optional)'),
|
||||
'',
|
||||
true
|
||||
)
|
||||
.addParameter(
|
||||
'scenevar',
|
||||
_(
|
||||
'Variable where to store the error message (optional, if an error occurs)'
|
||||
),
|
||||
'',
|
||||
true
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/sha256.js')
|
||||
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasLastSaveErrored',
|
||||
_('Last score save has errored'),
|
||||
_('Check if the last attempt to save a score has errored.'),
|
||||
_('Last score save in leaderboard _PARAM0_ has errored'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.hasSavingErrored');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasLastSaveSucceeded',
|
||||
_('Last score save has succeeded'),
|
||||
_('Check if the last attempt to save a score has succeeded.'),
|
||||
_('Last score save in leaderboard _PARAM0_ has succeeded'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action that successfully ended.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.hasBeenSaved');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsSaving',
|
||||
_('Score is saving'),
|
||||
_('Check if a score is currently being saved in leaderboard.'),
|
||||
_('Score is saving in leaderboard _PARAM0_'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isSaving');
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
'LastSaveError',
|
||||
_('Error of last save attempt'),
|
||||
_('Get the error of the last save attempt.'),
|
||||
_('Error of last save attempt in leaderboard _PARAM0_'),
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', true)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'If no leaderboard is specified, will return the value related to the last leaderboard save action.'
|
||||
)
|
||||
)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.getLastSaveError');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsLeaderboardViewErrored',
|
||||
_('Leaderboard display has errored'),
|
||||
_('Check if the display of the leaderboard errored.'),
|
||||
_('Leaderboard display has errored'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewErrored');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsLeaderboardViewLoaded',
|
||||
_('Leaderboard display has loaded'),
|
||||
_(
|
||||
'Check if the display of the leaderboard has finished loading and been displayed on screen.'
|
||||
),
|
||||
_('Leaderboard display has loaded and is displayed on screen'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewLoaded');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'IsLeaderboardViewLoading',
|
||||
_('Leaderboard display is loading'),
|
||||
_('Check if the display of the leaderboard is loading.'),
|
||||
_('Leaderboard display is loading'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isLeaderboardViewLoading');
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
'FormatPlayerName',
|
||||
_('Format player name'),
|
||||
_('Formats a name so that it can be submitted to a leaderboard.'),
|
||||
_('Save score'),
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addParameter('string', _('Raw player name'), '', false)
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.formatPlayerName');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'DisplayLeaderboard',
|
||||
_('Display leaderboard'),
|
||||
_(
|
||||
'Display the specified leaderboard on top of the game. If a leaderboard was already displayed on top of the game, the new leaderboard will replace it.'
|
||||
),
|
||||
_('Display leaderboard _PARAM1_ (display a loader: _PARAM2_)'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.addParameter('leaderboardId', _('Leaderboard'), '', false)
|
||||
.addParameter(
|
||||
'yesorno',
|
||||
_('Display loader while leaderboard is loading'),
|
||||
'',
|
||||
false
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.displayLeaderboard');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
'CloseLeaderboardView',
|
||||
_('Close current leaderboard'),
|
||||
_('Close the leaderboard currently displayed on top of the game.'),
|
||||
_('Close current leaderboard displayed on top of the game'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.addCodeOnlyParameter('currentScene', '')
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.closeLeaderboardView');
|
||||
|
||||
return extension;
|
||||
},
|
||||
runExtensionSanityTests: function (
|
||||
gd /*: libGDevelop */,
|
||||
extension /*: gdPlatformExtension*/
|
||||
) {
|
||||
return [];
|
||||
},
|
||||
};
|
653
Extensions/Leaderboards/leaderboardstools.ts
Normal file
653
Extensions/Leaderboards/leaderboardstools.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
/// <reference path="sha256.d.ts" />
|
||||
|
||||
namespace gdjs {
|
||||
const logger = new gdjs.Logger('Leaderboards');
|
||||
export namespace evtTools {
|
||||
export namespace leaderboards {
|
||||
const computeDigest = (payload: string): string => {
|
||||
const shaObj = new jsSHA('SHA-256', 'TEXT', { encoding: 'UTF8' });
|
||||
shaObj.update(payload);
|
||||
return shaObj.getHash('B64');
|
||||
};
|
||||
|
||||
// Score saving
|
||||
class ScoreSavingState {
|
||||
lastScoreSavingStartedAt: number | null;
|
||||
lastScoreSavingSucceededAt: number | null;
|
||||
currentlySavingScore: number | null;
|
||||
currentlySavingPlayerName: string | null;
|
||||
lastSavedScore: number | null;
|
||||
lastSavedPlayerName: string | null;
|
||||
lastSaveError: string | null;
|
||||
isScoreSaving: boolean;
|
||||
hasScoreBeenSaved: boolean;
|
||||
hasScoreSavingErrored: boolean;
|
||||
|
||||
constructor() {
|
||||
this.lastScoreSavingStartedAt = null;
|
||||
this.lastScoreSavingSucceededAt = null;
|
||||
this.currentlySavingScore = null;
|
||||
this.currentlySavingPlayerName = null;
|
||||
this.lastSavedScore = null;
|
||||
this.lastSavedPlayerName = null;
|
||||
this.lastSaveError = null;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
}
|
||||
|
||||
isSameAsLastScore(playerName: string, score: number): boolean {
|
||||
return (
|
||||
this.lastSavedPlayerName === playerName &&
|
||||
this.lastSavedScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isAlreadySavingThisScore(playerName: string, score: number): boolean {
|
||||
return (
|
||||
this.isScoreSaving &&
|
||||
this.currentlySavingPlayerName === playerName &&
|
||||
this.currentlySavingScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isTooSoonToSaveAnotherScore(): boolean {
|
||||
return (
|
||||
!!this.lastScoreSavingSucceededAt &&
|
||||
Date.now() - this.lastScoreSavingSucceededAt < 500
|
||||
);
|
||||
}
|
||||
|
||||
startSaving(playerName: string, score: number): void {
|
||||
this.lastScoreSavingStartedAt = Date.now();
|
||||
this.isScoreSaving = true;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
this.currentlySavingScore = score;
|
||||
this.currentlySavingPlayerName = playerName;
|
||||
}
|
||||
|
||||
closeSaving(): void {
|
||||
this.lastScoreSavingSucceededAt = Date.now();
|
||||
this.lastSavedScore = this.currentlySavingScore;
|
||||
this.lastSavedPlayerName = this.currentlySavingPlayerName;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = true;
|
||||
}
|
||||
|
||||
setError(errorCode: string): void {
|
||||
this.lastSaveError = errorCode;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = true;
|
||||
}
|
||||
}
|
||||
|
||||
let _scoreSavingStateByLeaderboard: {
|
||||
[leaderboardId: string]: ScoreSavingState;
|
||||
} = {};
|
||||
|
||||
// Leaderboard display
|
||||
let _requestedLeaderboardId: string | null;
|
||||
let _leaderboardViewIframe: HTMLIFrameElement | null = null;
|
||||
let _leaderboardViewIframeErrored: boolean = false;
|
||||
let _leaderboardViewIframeLoading: boolean = false;
|
||||
let _leaderboardViewIframeLoaded: boolean = false;
|
||||
let _errorTimeoutId: NodeJS.Timeout | null = null;
|
||||
let _leaderboardViewClosingCallback:
|
||||
| ((event: MessageEvent) => void)
|
||||
| null = null;
|
||||
|
||||
const _loaderContainer: HTMLDivElement = document.createElement('div');
|
||||
_loaderContainer.style.backgroundColor = '#000000';
|
||||
_loaderContainer.style.display = 'flex';
|
||||
_loaderContainer.style.height = '100%';
|
||||
_loaderContainer.style.width = '100%';
|
||||
_loaderContainer.style.justifyContent = 'center';
|
||||
_loaderContainer.style.alignItems = 'center';
|
||||
const _loader = document.createElement('img');
|
||||
_loader.setAttribute('width', '50px');
|
||||
_loader.setAttribute(
|
||||
'src',
|
||||
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgb3BhY2l0eT0nMC4yNScgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iNCI+PC9jaXJjbGU+CjxwYXRoIG9wYWNpdHk9JzAuNzUnIGZpbGw9IiNGRkZGRkYiIGQ9Ik00IDEyYTggOCAwIDAxOC04VjBDNS4zNzMgMCAwIDUuMzczIDAgMTJoNHptMiA1LjI5MUE3Ljk2MiA3Ljk2MiAwIDAxNCAxMkgwYzAgMy4wNDIgMS4xMzUgNS44MjQgMyA3LjkzOGwzLTIuNjQ3eiI+PC9wYXRoPgo8L3N2Zz4='
|
||||
);
|
||||
try {
|
||||
_loader.animate(
|
||||
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(359deg)' }],
|
||||
{
|
||||
duration: 3000,
|
||||
iterations: Infinity,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
logger.warn('Animation not supported, loader will be fixed.');
|
||||
}
|
||||
_loaderContainer.appendChild(_loader);
|
||||
|
||||
const getLastScoreSavingState = function ({
|
||||
hasSucceeded,
|
||||
}: {
|
||||
hasSucceeded: boolean;
|
||||
}): ScoreSavingState | null {
|
||||
const getDateField = (scoreSavingState: ScoreSavingState) =>
|
||||
hasSucceeded
|
||||
? scoreSavingState.lastScoreSavingSucceededAt
|
||||
: scoreSavingState.lastScoreSavingStartedAt;
|
||||
const scoreSavingStates = Object.values(
|
||||
_scoreSavingStateByLeaderboard
|
||||
).filter((scoreSavingState) => !!getDateField(scoreSavingState));
|
||||
if (scoreSavingStates.length === 0) return null;
|
||||
|
||||
let lastScoreSavingState = scoreSavingStates[0];
|
||||
scoreSavingStates.forEach((scoreSavingState) => {
|
||||
const currentItemDate = getDateField(scoreSavingState);
|
||||
const lastItemDate = getDateField(lastScoreSavingState);
|
||||
if (
|
||||
currentItemDate &&
|
||||
lastItemDate &&
|
||||
currentItemDate > lastItemDate
|
||||
) {
|
||||
lastScoreSavingState = scoreSavingState;
|
||||
}
|
||||
});
|
||||
return lastScoreSavingState;
|
||||
};
|
||||
|
||||
export const savePlayerScore = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float,
|
||||
playerName: string,
|
||||
responseVar: gdjs.Variable,
|
||||
errorVar: gdjs.Variable
|
||||
) {
|
||||
let scoreSavingState: ScoreSavingState;
|
||||
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
|
||||
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
if (scoreSavingState.isAlreadySavingThisScore(playerName, score)) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player name and this score. Ignoring this one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isSameAsLastScore(playerName, score)) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'SAME_AS_PREVIOUS';
|
||||
scoreSavingState.setError(errorCode);
|
||||
errorVar.setString(errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scoreSavingState.isTooSoonToSaveAnotherScore()) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
const errorCode = 'TOO_FAST';
|
||||
scoreSavingState.setError(errorCode);
|
||||
errorVar.setString(errorCode);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
|
||||
errorVar.setString('');
|
||||
responseVar.setString('');
|
||||
scoreSavingState.startSaving(playerName, score);
|
||||
|
||||
const baseUrl = 'https://api.gdevelop-app.com/play';
|
||||
const game = runtimeScene.getGame();
|
||||
const payload = JSON.stringify({
|
||||
playerName: formatPlayerName(playerName),
|
||||
score: score,
|
||||
sessionId: game.getSessionId(),
|
||||
clientPlayerId: game.getPlayerId(),
|
||||
location:
|
||||
typeof window !== 'undefined' && (window as any).location
|
||||
? (window as any).location.href
|
||||
: '',
|
||||
});
|
||||
fetch(
|
||||
`${baseUrl}/game/${gdjs.projectData.properties.projectUuid}/leaderboard/${leaderboardId}/entry`,
|
||||
{
|
||||
body: payload,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Digest: computeDigest(payload),
|
||||
},
|
||||
}
|
||||
).then(
|
||||
(response) => {
|
||||
if (!response.ok) {
|
||||
const errorCode = response.status.toString();
|
||||
logger.error(
|
||||
'Server responded with an error:',
|
||||
errorCode,
|
||||
response.statusText
|
||||
);
|
||||
scoreSavingState.setError(errorCode);
|
||||
errorVar.setString(errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
scoreSavingState.closeSaving();
|
||||
|
||||
return response.text().then(
|
||||
(text) => {
|
||||
responseVar.setString(text);
|
||||
},
|
||||
(error) => {
|
||||
logger.warn(
|
||||
'An error occurred when reading response but score has been saved:',
|
||||
error
|
||||
);
|
||||
responseVar.setString('CANNOT_READ_RESPONSE');
|
||||
}
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Error while submitting a leaderboard score:', error);
|
||||
const errorCode = 'REQUEST_NOT_SENT';
|
||||
scoreSavingState.setError(errorCode);
|
||||
errorVar.setString(errorCode);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const isSaving = function (leaderboardId?: string): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].isScoreSaving
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.isScoreSaving
|
||||
: false;
|
||||
};
|
||||
|
||||
export const hasBeenSaved = function (leaderboardId?: string): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].hasScoreBeenSaved
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: true,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.hasScoreBeenSaved
|
||||
: false;
|
||||
};
|
||||
|
||||
export const hasSavingErrored = function (
|
||||
leaderboardId?: string
|
||||
): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
.hasScoreSavingErrored
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.hasScoreSavingErrored
|
||||
: false;
|
||||
};
|
||||
|
||||
export const getLastSaveError = function (
|
||||
leaderboardId?: string
|
||||
): string | null {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].lastSaveError
|
||||
: 'NO_DATA_ERROR';
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.lastSaveError
|
||||
: 'NO_DATA_ERROR';
|
||||
};
|
||||
|
||||
export const formatPlayerName = function (rawName: string): string {
|
||||
if (
|
||||
!rawName ||
|
||||
typeof rawName !== 'string' ||
|
||||
(typeof rawName === 'string' && rawName.length === 0)
|
||||
) {
|
||||
return `Player${Math.round(
|
||||
(Math.random() * 9 + 1) * 10000 // Number between 10,000 and 99,999
|
||||
)}`;
|
||||
}
|
||||
return rawName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s/g, '_')
|
||||
.replace(/[^\w|-]/g, '')
|
||||
.slice(0, 30);
|
||||
};
|
||||
|
||||
const checkLeaderboardAvailability = function (
|
||||
url: string
|
||||
): Promise<boolean> {
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}).then(
|
||||
(response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`Error while fetching leaderboard view, server returned: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(err) => {
|
||||
logger.error('Error while fetching leaderboard view:', err);
|
||||
return false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const receiveMessageFromLeaderboardView = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
displayLoader: boolean,
|
||||
event: MessageEvent
|
||||
) {
|
||||
switch (event.data) {
|
||||
case 'closeLeaderboardView':
|
||||
closeLeaderboardView(runtimeScene);
|
||||
break;
|
||||
case 'leaderboardViewLoaded':
|
||||
if (displayLoader) {
|
||||
if (_errorTimeoutId) clearTimeout(_errorTimeoutId);
|
||||
displayLoaderInLeaderboardView(false, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
}
|
||||
if (!_leaderboardViewIframe) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The leaderboard view couldn't be found. Doing nothing."
|
||||
);
|
||||
return;
|
||||
}
|
||||
_leaderboardViewIframe.style.opacity = '1';
|
||||
_leaderboardViewIframeLoaded = true;
|
||||
_leaderboardViewIframeLoading = false;
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleErrorDisplayingLeaderboard = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
message: string
|
||||
) {
|
||||
logger.error(message);
|
||||
_leaderboardViewIframeErrored = true;
|
||||
_leaderboardViewIframeLoading = false;
|
||||
closeLeaderboardView(runtimeScene);
|
||||
};
|
||||
|
||||
const resetLeaderboardDisplayErrorTimeout = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) => {
|
||||
if (_errorTimeoutId) clearTimeout(_errorTimeoutId);
|
||||
_errorTimeoutId = setTimeout(() => {
|
||||
if (!_leaderboardViewIframeLoaded) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'Leaderboard page did not send message in time. Closing leaderboard view.'
|
||||
);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const displayLoaderInLeaderboardView = function (
|
||||
yesOrNo: boolean,
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
options: { callOnErrorIfDomElementContainerMissing: boolean }
|
||||
): boolean {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
if (options.callOnErrorIfDomElementContainerMissing) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (yesOrNo) {
|
||||
if (
|
||||
domElementContainer.children &&
|
||||
domElementContainer.children.length > 0
|
||||
) {
|
||||
domElementContainer.insertBefore(
|
||||
_loaderContainer,
|
||||
domElementContainer.children[0]
|
||||
);
|
||||
} else {
|
||||
domElementContainer.appendChild(_loaderContainer);
|
||||
}
|
||||
if (_leaderboardViewIframe) {
|
||||
_leaderboardViewIframe.style.opacity = '0';
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
domElementContainer.removeChild(_loaderContainer);
|
||||
if (_leaderboardViewIframe) {
|
||||
_leaderboardViewIframe.style.opacity = '1';
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const computeLeaderboardDisplayingIframe = function (
|
||||
url: string
|
||||
): HTMLIFrameElement {
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.src = url;
|
||||
iframe.id = 'leaderboard-view';
|
||||
iframe.style.position = 'absolute';
|
||||
// To trigger iframe loading and be able to listen to its events, use `opacity: 0` instead of `visibility: hidden` or `display: none`
|
||||
iframe.style.opacity = '0';
|
||||
iframe.style.pointerEvents = 'all';
|
||||
iframe.style.backgroundColor = '#FFFFFF';
|
||||
iframe.style.top = '0px';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.left = '0px';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.border = 'none';
|
||||
|
||||
return iframe;
|
||||
};
|
||||
|
||||
export const displayLeaderboard = function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
displayLoader: boolean
|
||||
) {
|
||||
// First ensure we're not trying to display multiple times the same leaderboard (in which case
|
||||
// we "de-duplicate" the request to display it).
|
||||
if (leaderboardId === _requestedLeaderboardId) {
|
||||
if (_leaderboardViewIframeLoading) {
|
||||
logger.warn(
|
||||
`Already loading the view for the requested loader (${leaderboardId}), ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_leaderboardViewIframeLoaded) {
|
||||
logger.warn(
|
||||
`Already loaded the view for the requested loader (${leaderboardId}), ignoring.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// We are now assured we want to display a new (or different) leaderboard: start loading it.
|
||||
_requestedLeaderboardId = leaderboardId;
|
||||
_leaderboardViewIframeErrored = false;
|
||||
_leaderboardViewIframeLoaded = false;
|
||||
_leaderboardViewIframeLoading = true;
|
||||
|
||||
if (displayLoader) {
|
||||
displayLoaderInLeaderboardView(true, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: true,
|
||||
});
|
||||
}
|
||||
|
||||
const gameId = gdjs.projectData.properties.projectUuid;
|
||||
const targetUrl = `https://liluo.io/games/${gameId}/leaderboard/${leaderboardId}?inGameEmbedded=true`;
|
||||
checkLeaderboardAvailability(targetUrl).then(
|
||||
(isAvailable) => {
|
||||
if (leaderboardId !== _requestedLeaderboardId) {
|
||||
logger.warn(
|
||||
`Received a response for leaderboard ${leaderboardId} though the last leaderboard requested is ${_requestedLeaderboardId}, ignoring this response.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isAvailable) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'Leaderboard data could not be fetched. Closing leaderboard view if there is one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_leaderboardViewIframe) {
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
if (displayLoader) {
|
||||
displayLoaderInLeaderboardView(true, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
}
|
||||
_leaderboardViewIframe.src = targetUrl;
|
||||
} else {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
|
||||
_leaderboardViewIframe = computeLeaderboardDisplayingIframe(
|
||||
targetUrl
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
_leaderboardViewClosingCallback = (event: MessageEvent) => {
|
||||
receiveMessageFromLeaderboardView(
|
||||
runtimeScene,
|
||||
displayLoader,
|
||||
event
|
||||
);
|
||||
};
|
||||
(window as any).addEventListener(
|
||||
'message',
|
||||
_leaderboardViewClosingCallback,
|
||||
true
|
||||
);
|
||||
}
|
||||
domElementContainer.appendChild(_leaderboardViewIframe);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
logger.error(err);
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'An error occurred when fetching leaderboard data. Closing leaderboard view if there is one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const isLeaderboardViewErrored = function (): boolean {
|
||||
return _leaderboardViewIframeErrored;
|
||||
};
|
||||
|
||||
export const isLeaderboardViewLoaded = function (): boolean {
|
||||
return _leaderboardViewIframeLoaded;
|
||||
};
|
||||
|
||||
export const isLeaderboardViewLoading = function (): boolean {
|
||||
return _leaderboardViewIframeLoading;
|
||||
};
|
||||
|
||||
export const closeLeaderboardView = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
try {
|
||||
displayLoaderInLeaderboardView(false, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
|
||||
if (!_leaderboardViewIframe) {
|
||||
logger.info(
|
||||
"The iframe displaying the current leaderboard couldn't be found, the leaderboard view must be already closed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
logger.info(
|
||||
"The div element covering the game couldn't be found, the leaderboard view must be already closed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).removeEventListener(
|
||||
'message',
|
||||
_leaderboardViewClosingCallback,
|
||||
true
|
||||
);
|
||||
_leaderboardViewClosingCallback = null;
|
||||
}
|
||||
domElementContainer.removeChild(_leaderboardViewIframe);
|
||||
_leaderboardViewIframe = null;
|
||||
} finally {
|
||||
// Don't reset the loading flag (the view of another leaderboard might be loading)
|
||||
// or the error flag (we want to persist the error flag even after the view is closed),
|
||||
// but reset the flag indicating the view is loaded (if it was).
|
||||
_leaderboardViewIframeLoaded = false;
|
||||
|
||||
const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas();
|
||||
if (gameCanvas) gameCanvas.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
234
Extensions/Leaderboards/sha256.d.ts
vendored
Normal file
234
Extensions/Leaderboards/sha256.d.ts
vendored
Normal file
@@ -0,0 +1,234 @@
|
||||
declare type EncodingType = 'UTF8' | 'UTF16BE' | 'UTF16LE';
|
||||
declare type FormatNoTextType =
|
||||
| 'HEX'
|
||||
| 'B64'
|
||||
| 'BYTES'
|
||||
| 'ARRAYBUFFER'
|
||||
| 'UINT8ARRAY';
|
||||
declare type FormatType = 'TEXT' | FormatNoTextType;
|
||||
declare type GenericInputType =
|
||||
| {
|
||||
value: string;
|
||||
format: 'TEXT';
|
||||
encoding?: EncodingType;
|
||||
}
|
||||
| {
|
||||
value: string;
|
||||
format: 'B64' | 'HEX' | 'BYTES';
|
||||
}
|
||||
| {
|
||||
value: ArrayBuffer;
|
||||
format: 'ARRAYBUFFER';
|
||||
}
|
||||
| {
|
||||
value: Uint8Array;
|
||||
format: 'UINT8ARRAY';
|
||||
};
|
||||
declare type FixedLengthOptionsNoEncodingType =
|
||||
| {
|
||||
hmacKey?: GenericInputType;
|
||||
}
|
||||
| {
|
||||
numRounds?: number;
|
||||
};
|
||||
declare type FixedLengthOptionsEncodingType =
|
||||
| {
|
||||
hmacKey?: GenericInputType;
|
||||
encoding?: EncodingType;
|
||||
}
|
||||
| {
|
||||
numRounds?: number;
|
||||
encoding?: EncodingType;
|
||||
};
|
||||
interface packedValue {
|
||||
value: number[];
|
||||
binLen: number;
|
||||
}
|
||||
|
||||
declare abstract class jsSHABase<StateT, VariantT> {
|
||||
/**
|
||||
* @param variant The desired SHA variant.
|
||||
* @param inputFormat The input format to be used in future `update` calls.
|
||||
* @param options Hashmap of extra input options.
|
||||
*/
|
||||
protected readonly shaVariant: VariantT;
|
||||
protected readonly inputFormat: FormatType;
|
||||
protected readonly utfType: EncodingType;
|
||||
protected readonly numRounds: number;
|
||||
protected abstract intermediateState: StateT;
|
||||
protected keyWithIPad: number[];
|
||||
protected keyWithOPad: number[];
|
||||
protected remainder: number[];
|
||||
protected remainderLen: number;
|
||||
protected updateCalled: boolean;
|
||||
protected processedLen: number;
|
||||
protected macKeySet: boolean;
|
||||
protected abstract readonly variantBlockSize: number;
|
||||
protected abstract readonly bigEndianMod: -1 | 1;
|
||||
protected abstract readonly outputBinLen: number;
|
||||
protected abstract readonly isVariableLen: boolean;
|
||||
protected abstract readonly HMACSupported: boolean;
|
||||
protected abstract readonly converterFunc: (
|
||||
input: any,
|
||||
existingBin: number[],
|
||||
existingBinLen: number
|
||||
) => packedValue;
|
||||
protected abstract readonly roundFunc: (block: number[], H: StateT) => StateT;
|
||||
protected abstract readonly finalizeFunc: (
|
||||
remainder: number[],
|
||||
remainderBinLen: number,
|
||||
processedBinLen: number,
|
||||
H: StateT,
|
||||
outputLen: number
|
||||
) => number[];
|
||||
protected abstract readonly stateCloneFunc: (state: StateT) => StateT;
|
||||
protected abstract readonly newStateFunc: (variant: VariantT) => StateT;
|
||||
protected abstract readonly getMAC:
|
||||
| ((options: { outputLen: number }) => number[])
|
||||
| null;
|
||||
protected constructor(
|
||||
variant: VariantT,
|
||||
inputFormat: 'TEXT',
|
||||
options?: FixedLengthOptionsEncodingType
|
||||
);
|
||||
protected constructor(
|
||||
variant: VariantT,
|
||||
inputFormat: FormatNoTextType,
|
||||
options?: FixedLengthOptionsNoEncodingType
|
||||
);
|
||||
/**
|
||||
* Hashes as many blocks as possible. Stores the rest for either a future update or getHash call.
|
||||
*
|
||||
* @param srcString The input to be hashed.
|
||||
*/
|
||||
update(srcString: string | ArrayBuffer | Uint8Array): void;
|
||||
/**
|
||||
* Returns the desired SHA hash of the input fed in via `update` calls.
|
||||
*
|
||||
* @param format The desired output formatting
|
||||
* @param options Hashmap of output formatting options. `outputLen` must be specified for variable length hashes.
|
||||
* `outputLen` replaces the now deprecated `shakeLen` key.
|
||||
* @returns The hash in the format specified.
|
||||
*/
|
||||
getHash(
|
||||
format: 'HEX',
|
||||
options?: {
|
||||
outputUpper?: boolean;
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): string;
|
||||
getHash(
|
||||
format: 'B64',
|
||||
options?: {
|
||||
b64Pad?: string;
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): string;
|
||||
getHash(
|
||||
format: 'BYTES',
|
||||
options?: {
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): string;
|
||||
getHash(
|
||||
format: 'UINT8ARRAY',
|
||||
options?: {
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): Uint8Array;
|
||||
getHash(
|
||||
format: 'ARRAYBUFFER',
|
||||
options?: {
|
||||
outputLen?: number;
|
||||
shakeLen?: number;
|
||||
}
|
||||
): ArrayBuffer;
|
||||
/**
|
||||
* Sets the HMAC key for an eventual `getHMAC` call. Must be called immediately after jsSHA object instantiation.
|
||||
*
|
||||
* @param key The key used to calculate the HMAC
|
||||
* @param inputFormat The format of key.
|
||||
* @param options Hashmap of extra input options.
|
||||
*/
|
||||
setHMACKey(
|
||||
key: string,
|
||||
inputFormat: 'TEXT',
|
||||
options?: {
|
||||
encoding?: EncodingType;
|
||||
}
|
||||
): void;
|
||||
setHMACKey(key: string, inputFormat: 'B64' | 'HEX' | 'BYTES'): void;
|
||||
setHMACKey(key: ArrayBuffer, inputFormat: 'ARRAYBUFFER'): void;
|
||||
setHMACKey(key: Uint8Array, inputFormat: 'UINT8ARRAY'): void;
|
||||
/**
|
||||
* Internal function that sets the MAC key.
|
||||
*
|
||||
* @param key The packed MAC key to use
|
||||
*/
|
||||
protected _setHMACKey(key: packedValue): void;
|
||||
/**
|
||||
* Returns the the HMAC in the specified format using the key given by a previous `setHMACKey` call.
|
||||
*
|
||||
* @param format The desired output formatting.
|
||||
* @param options Hashmap of extra outputs options.
|
||||
* @returns The HMAC in the format specified.
|
||||
*/
|
||||
getHMAC(
|
||||
format: 'HEX',
|
||||
options?: {
|
||||
outputUpper?: boolean;
|
||||
}
|
||||
): string;
|
||||
getHMAC(
|
||||
format: 'B64',
|
||||
options?: {
|
||||
b64Pad?: string;
|
||||
}
|
||||
): string;
|
||||
getHMAC(format: 'BYTES'): string;
|
||||
getHMAC(format: 'UINT8ARRAY'): Uint8Array;
|
||||
getHMAC(format: 'ARRAYBUFFER'): ArrayBuffer;
|
||||
/**
|
||||
* Internal function that returns the "raw" HMAC
|
||||
*/
|
||||
protected _getHMAC(): number[];
|
||||
}
|
||||
|
||||
declare type VariantType = 'SHA-224' | 'SHA-256';
|
||||
declare class jsSHA extends jsSHABase<number[], VariantType> {
|
||||
intermediateState: number[];
|
||||
variantBlockSize: number;
|
||||
bigEndianMod: -1 | 1;
|
||||
outputBinLen: number;
|
||||
isVariableLen: boolean;
|
||||
HMACSupported: boolean;
|
||||
converterFunc: (
|
||||
input: any,
|
||||
existingBin: number[],
|
||||
existingBinLen: number
|
||||
) => packedValue;
|
||||
roundFunc: (block: number[], H: number[]) => number[];
|
||||
finalizeFunc: (
|
||||
remainder: number[],
|
||||
remainderBinLen: number,
|
||||
processedBinLen: number,
|
||||
H: number[]
|
||||
) => number[];
|
||||
stateCloneFunc: (state: number[]) => number[];
|
||||
newStateFunc: (variant: VariantType) => number[];
|
||||
getMAC: () => number[];
|
||||
constructor(
|
||||
variant: VariantType,
|
||||
inputFormat: 'TEXT',
|
||||
options?: FixedLengthOptionsEncodingType
|
||||
);
|
||||
constructor(
|
||||
variant: VariantType,
|
||||
inputFormat: FormatNoTextType,
|
||||
options?: FixedLengthOptionsNoEncodingType
|
||||
);
|
||||
}
|
21
Extensions/Leaderboards/sha256.js
Normal file
21
Extensions/Leaderboards/sha256.js
Normal file
File diff suppressed because one or more lines are too long
68
Extensions/Leaderboards/tests/leaderboardstools.spec.js
Normal file
68
Extensions/Leaderboards/tests/leaderboardstools.spec.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// @ts-check
|
||||
|
||||
describe('Leaderboards', () => {
|
||||
describe('formatPlayerName', () => {
|
||||
it('it returns name if correct', () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('PlayerName')).to.be(
|
||||
'PlayerName'
|
||||
);
|
||||
});
|
||||
|
||||
it('it returns name with underscores instead of whitespaces', () => {
|
||||
expect(
|
||||
gdjs.evtTools.leaderboards.formatPlayerName('My Player Name')
|
||||
).to.be('My_Player_Name');
|
||||
});
|
||||
|
||||
it("it doesn't change a name with vertical bars and hyphens", () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('Pla-yer|Name')).to.be(
|
||||
'Pla-yer|Name'
|
||||
);
|
||||
});
|
||||
|
||||
it('it truncates name if longer than 30', () => {
|
||||
expect(
|
||||
gdjs.evtTools.leaderboards.formatPlayerName(
|
||||
'aPlayerNameTh4tIsT00LongToBeSaved'
|
||||
)
|
||||
).to.be('aPlayerNameTh4tIsT00LongToBeSa');
|
||||
});
|
||||
|
||||
it('it generates a predefined player name with a random number if input is void/wrong type/empty', () => {
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(null)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(5)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(undefined)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(() => {})).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('')).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
});
|
||||
|
||||
it('it removes accents from latin letters', () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('plâyèrÏonisé')).to.be(
|
||||
'playerIonise'
|
||||
);
|
||||
});
|
||||
|
||||
it('it removes non-accepted characters in a long name', () => {
|
||||
expect(
|
||||
gdjs.evtTools.leaderboards.formatPlayerName(
|
||||
'aPιΥÉᚱnÀⅯeThatᎥsTooⅬonᏀToBeՏaѵÊĐThisPartAppears'
|
||||
)
|
||||
).to.be('aEAeThatsTooonToBeaEThisPartAp');
|
||||
});
|
||||
});
|
||||
});
|
@@ -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",
|
||||
|
@@ -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");
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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)) ||
|
||||
|
@@ -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(' ', {
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
@@ -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
|
||||
);
|
||||
|
@@ -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"),
|
||||
|
@@ -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");
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -99,6 +99,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateDownKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getXVelocity()
|
||||
).to.be.above(0);
|
||||
expect(player.getBehavior(topDownName).getYVelocity()).to.be(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be.above(200 + 20);
|
||||
@@ -120,6 +124,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateUpKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getXVelocity()
|
||||
).to.be.below(0);
|
||||
expect(player.getBehavior(topDownName).getYVelocity()).to.be(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be.below(200 - 20);
|
||||
@@ -141,6 +149,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateLeftKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(player.getBehavior(topDownName).getXVelocity()).to.be(0);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getYVelocity()
|
||||
).to.be.above(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be(200);
|
||||
@@ -162,6 +174,10 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
|
||||
player.getBehavior(topDownName).simulateLeftKey();
|
||||
}
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
expect(player.getBehavior(topDownName).getXVelocity()).to.be(0);
|
||||
expect(
|
||||
player.getBehavior(topDownName).getYVelocity()
|
||||
).to.be.below(0);
|
||||
}
|
||||
|
||||
expect(player.getX()).to.be(200);
|
||||
|
@@ -35,10 +35,11 @@ namespace gdjs {
|
||||
private _rightKey: boolean = false;
|
||||
private _upKey: boolean = false;
|
||||
private _downKey: boolean = false;
|
||||
private _leftKeyPressedDuration: integer = -1;
|
||||
private _rightKeyPressedDuration: integer = -1;
|
||||
private _upKeyPressedDuration: integer = -1;
|
||||
private _downKeyPressedDuration: integer = -1;
|
||||
private _leftKeyPressedDuration: float = 0;
|
||||
private _rightKeyPressedDuration: float = 0;
|
||||
private _upKeyPressedDuration: float = 0;
|
||||
private _downKeyPressedDuration: float = 0;
|
||||
private _wasStickUsed: boolean = false;
|
||||
private _stickAngle: float = 0;
|
||||
private _stickForce: float = 0;
|
||||
|
||||
@@ -192,20 +193,28 @@ namespace gdjs {
|
||||
return this._xVelocity !== 0 || this._yVelocity !== 0;
|
||||
}
|
||||
|
||||
getSpeed() {
|
||||
getSpeed(): float {
|
||||
return Math.sqrt(
|
||||
this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity
|
||||
);
|
||||
}
|
||||
|
||||
getXVelocity() {
|
||||
getXVelocity(): float {
|
||||
return this._xVelocity;
|
||||
}
|
||||
|
||||
getYVelocity() {
|
||||
setXVelocity(velocityX: float): void {
|
||||
this._xVelocity = velocityX;
|
||||
}
|
||||
|
||||
getYVelocity(): float {
|
||||
return this._yVelocity;
|
||||
}
|
||||
|
||||
setYVelocity(velocityY: float): void {
|
||||
this._yVelocity = velocityY;
|
||||
}
|
||||
|
||||
getAngle(): float {
|
||||
return this._angle;
|
||||
}
|
||||
@@ -242,31 +251,31 @@ namespace gdjs {
|
||||
!this._ignoreDefaultControls &&
|
||||
runtimeScene.getGame().getInputManager().isKeyPressed(UPKEY);
|
||||
|
||||
const elapsedTime = this.owner.getElapsedTime(runtimeScene);
|
||||
|
||||
if (!this._leftKey) {
|
||||
this._leftKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._leftKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._rightKey) {
|
||||
this._rightKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._rightKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._downKey) {
|
||||
this._downKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._downKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._upKey) {
|
||||
this._upKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._upKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
|
||||
let direction = -1;
|
||||
if (!this._allowDiagonals) {
|
||||
const elapsedTime = this.owner.getElapsedTime(runtimeScene);
|
||||
|
||||
if (!this._leftKey) {
|
||||
this._leftKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._leftKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._rightKey) {
|
||||
this._rightKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._rightKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._downKey) {
|
||||
this._downKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._downKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
if (!this._upKey) {
|
||||
this._upKeyPressedDuration = 0;
|
||||
} else {
|
||||
this._upKeyPressedDuration += elapsedTime;
|
||||
}
|
||||
|
||||
if (this._upKey && !this._downKey) {
|
||||
direction = 6;
|
||||
} else if (!this._upKey && this._downKey) {
|
||||
@@ -322,20 +331,34 @@ namespace gdjs {
|
||||
|
||||
const object = this.owner;
|
||||
const timeDelta = this.owner.getElapsedTime(runtimeScene) / 1000;
|
||||
let directionInRad = 0;
|
||||
let directionInDeg = 0;
|
||||
const previousVelocityX = this._xVelocity;
|
||||
const previousVelocityY = this._yVelocity;
|
||||
this._wasStickUsed = false;
|
||||
|
||||
// These 4 values are not actually used.
|
||||
// JavaScript doesn't allow to declare
|
||||
// variables without assigning them a value.
|
||||
let directionInRad = 0;
|
||||
let directionInDeg = 0;
|
||||
let cos = 1;
|
||||
let sin = 0;
|
||||
|
||||
// Update the speed of the object:
|
||||
if (direction !== -1) {
|
||||
directionInRad =
|
||||
((direction + this._movementAngleOffset / 45) * Math.PI) / 4.0;
|
||||
directionInDeg = direction * 45 + this._movementAngleOffset;
|
||||
this._xVelocity +=
|
||||
this._acceleration * timeDelta * Math.cos(directionInRad);
|
||||
this._yVelocity +=
|
||||
this._acceleration * timeDelta * Math.sin(directionInRad);
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity += this._acceleration * timeDelta * cos;
|
||||
this._yVelocity += this._acceleration * timeDelta * sin;
|
||||
} else if (this._stickForce !== 0) {
|
||||
if (!this._allowDiagonals) {
|
||||
this._stickAngle = 90 * Math.floor((this._stickAngle + 45) / 90);
|
||||
@@ -343,19 +366,36 @@ namespace gdjs {
|
||||
directionInDeg = this._stickAngle + this._movementAngleOffset;
|
||||
directionInRad = (directionInDeg * Math.PI) / 180;
|
||||
const norm = this._acceleration * timeDelta * this._stickForce;
|
||||
this._xVelocity += norm * Math.cos(directionInRad);
|
||||
this._yVelocity += norm * Math.sin(directionInRad);
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity += norm * cos;
|
||||
this._yVelocity += norm * sin;
|
||||
|
||||
this._wasStickUsed = true;
|
||||
this._stickForce = 0;
|
||||
} else if (this._yVelocity !== 0 || this._xVelocity !== 0) {
|
||||
directionInRad = Math.atan2(this._yVelocity, this._xVelocity);
|
||||
directionInDeg = (directionInRad * 180.0) / Math.PI;
|
||||
const xVelocityWasPositive = this._xVelocity >= 0;
|
||||
const yVelocityWasPositive = this._yVelocity >= 0;
|
||||
this._xVelocity -=
|
||||
this._deceleration * timeDelta * Math.cos(directionInRad);
|
||||
this._yVelocity -=
|
||||
this._deceleration * timeDelta * Math.sin(directionInRad);
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity -= this._deceleration * timeDelta * cos;
|
||||
this._yVelocity -= this._deceleration * timeDelta * sin;
|
||||
if (this._xVelocity > 0 !== xVelocityWasPositive) {
|
||||
this._xVelocity = 0;
|
||||
}
|
||||
@@ -366,8 +406,8 @@ namespace gdjs {
|
||||
const squaredSpeed =
|
||||
this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity;
|
||||
if (squaredSpeed > this._maxSpeed * this._maxSpeed) {
|
||||
this._xVelocity = this._maxSpeed * Math.cos(directionInRad);
|
||||
this._yVelocity = this._maxSpeed * Math.sin(directionInRad);
|
||||
this._xVelocity = this._maxSpeed * cos;
|
||||
this._yVelocity = this._maxSpeed * sin;
|
||||
}
|
||||
|
||||
// No acceleration for angular speed for now.
|
||||
@@ -451,6 +491,33 @@ namespace gdjs {
|
||||
this._stickAngle = stickAngle % 360;
|
||||
this._stickForce = Math.max(0, Math.min(1, stickForce));
|
||||
}
|
||||
|
||||
/**.
|
||||
* @param input The control to be tested [Left,Right,Up,Down,Stick].
|
||||
* @returns true if the key was used since the last `doStepPreEvents` call.
|
||||
*/
|
||||
isUsingControl(input: string): boolean {
|
||||
if (input === 'Left') {
|
||||
return this._leftKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Right') {
|
||||
return this._rightKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Up') {
|
||||
return this._upKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Down') {
|
||||
return this._downKeyPressedDuration > 0;
|
||||
}
|
||||
if (input === 'Stick') {
|
||||
return this._wasStickUsed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getLastStickInputAngle() {
|
||||
return this._stickAngle;
|
||||
}
|
||||
}
|
||||
export namespace TopDownMovementRuntimeBehavior {
|
||||
export interface BasisTransformation {
|
||||
|
@@ -17,6 +17,8 @@ MouseExtension::MouseExtension() {
|
||||
"gdjs.evtTools.input.getMouseX");
|
||||
GetAllConditions()["MouseY"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getMouseY");
|
||||
GetAllConditions()["IsMouseInsideCanvas"].SetFunctionName(
|
||||
"gdjs.evtTools.input.isMouseInsideCanvas");
|
||||
GetAllConditions()["SourisX"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getMouseX"); // Deprecated
|
||||
GetAllConditions()["SourisY"].SetFunctionName(
|
||||
@@ -53,9 +55,9 @@ MouseExtension::MouseExtension() {
|
||||
"gdjs.evtTools.input.getMouseY"); // Deprecated
|
||||
|
||||
GetAllConditions()["PopStartedTouch"].SetFunctionName(
|
||||
"gdjs.evtTools.input.popStartedTouch");
|
||||
"gdjs.evtTools.input.popStartedTouch"); // Deprecated
|
||||
GetAllConditions()["PopEndedTouch"].SetFunctionName(
|
||||
"gdjs.evtTools.input.popEndedTouch");
|
||||
"gdjs.evtTools.input.popEndedTouch"); // Deprecated
|
||||
|
||||
GetAllConditions()["TouchX"].SetFunctionName("gdjs.evtTools.input.getTouchX");
|
||||
GetAllConditions()["TouchY"].SetFunctionName("gdjs.evtTools.input.getTouchY");
|
||||
@@ -65,9 +67,18 @@ MouseExtension::MouseExtension() {
|
||||
"gdjs.evtTools.input.getTouchY");
|
||||
|
||||
GetAllExpressions()["LastTouchId"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getLastTouchId");
|
||||
"gdjs.evtTools.input.getLastTouchId"); // Deprecated
|
||||
GetAllExpressions()["LastEndedTouchId"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getLastEndedTouchId");
|
||||
"gdjs.evtTools.input.getLastEndedTouchId"); // Deprecated
|
||||
|
||||
GetAllConditions()["HasAnyTouchStarted"].SetFunctionName(
|
||||
"gdjs.evtTools.input.hasAnyTouchStarted");
|
||||
GetAllConditions()["HasTouchEnded"].SetFunctionName(
|
||||
"gdjs.evtTools.input.hasTouchEnded");
|
||||
GetAllExpressions()["StartedTouchCount"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getStartedTouchCount");
|
||||
GetAllExpressions()["StartedTouchId"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getStartedTouchIdentifier");
|
||||
|
||||
GetAllExpressions()["MouseWheelDelta"].SetFunctionName(
|
||||
"gdjs.evtTools.input.getMouseWheelDelta");
|
||||
|
@@ -59,6 +59,8 @@ namespace gdjs {
|
||||
if (
|
||||
// Don't reload Box2d as it would confuse and crash the asm.js library.
|
||||
endsWith(srcFilename, 'box2d.js') ||
|
||||
// Don't reload sha256.js library.
|
||||
endsWith(srcFilename, 'sha256.js') ||
|
||||
// Don't reload shifty.js library.
|
||||
endsWith(srcFilename, 'shifty.js') ||
|
||||
// Don't reload shopify-buy library.
|
||||
|
@@ -6,7 +6,13 @@
|
||||
namespace gdjs {
|
||||
export namespace evtTools {
|
||||
export namespace input {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export let lastTouchId = 0;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export let lastEndedTouchId = 0;
|
||||
|
||||
/**
|
||||
@@ -266,6 +272,12 @@ namespace gdjs {
|
||||
)[1];
|
||||
};
|
||||
|
||||
export const isMouseInsideCanvas = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
return runtimeScene.getGame().getInputManager().isMouseInsideCanvas();
|
||||
};
|
||||
|
||||
export const _cursorIsOnObject = function (obj, runtimeScene) {
|
||||
return obj.cursorOnObject(runtimeScene);
|
||||
};
|
||||
@@ -285,10 +297,10 @@ namespace gdjs {
|
||||
};
|
||||
|
||||
export const getTouchX = function (
|
||||
runtimeScene,
|
||||
identifier,
|
||||
layer,
|
||||
camera
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
identifier: integer,
|
||||
layer: string,
|
||||
camera: integer
|
||||
) {
|
||||
return runtimeScene
|
||||
.getLayer(layer)
|
||||
@@ -298,12 +310,12 @@ namespace gdjs {
|
||||
)[0];
|
||||
};
|
||||
|
||||
export const getTouchY = function (
|
||||
runtimeScene,
|
||||
identifier,
|
||||
layer,
|
||||
camera
|
||||
) {
|
||||
export const getTouchY = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
identifier: integer,
|
||||
layer: string,
|
||||
camera: integer
|
||||
) => {
|
||||
return runtimeScene
|
||||
.getLayer(layer)
|
||||
.convertCoords(
|
||||
@@ -312,15 +324,64 @@ namespace gdjs {
|
||||
)[1];
|
||||
};
|
||||
|
||||
export const hasAnyTouchStarted = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): boolean => {
|
||||
return (
|
||||
runtimeScene.getGame().getInputManager().getStartedTouchIdentifiers()
|
||||
.length > 0
|
||||
);
|
||||
};
|
||||
|
||||
export const getStartedTouchCount = (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
): integer => {
|
||||
return runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
.getStartedTouchIdentifiers().length;
|
||||
};
|
||||
|
||||
export const getStartedTouchIdentifier = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
index: integer
|
||||
): integer => {
|
||||
return runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
.getStartedTouchIdentifiers()[index];
|
||||
};
|
||||
|
||||
export const hasTouchEnded = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
identifier: integer
|
||||
): boolean => {
|
||||
return runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
.hasTouchEnded(identifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLastTouchId = function () {
|
||||
return gdjs.evtTools.input.lastTouchId || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLastEndedTouchId = function () {
|
||||
return gdjs.evtTools.input.lastEndedTouchId || 0;
|
||||
};
|
||||
|
||||
export const popStartedTouch = function (runtimeScene) {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const popStartedTouch = function (
|
||||
runtimeScene: gdjs.RuntimeScene
|
||||
) {
|
||||
const startedTouchId = runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
@@ -332,7 +393,10 @@ namespace gdjs {
|
||||
return false;
|
||||
};
|
||||
|
||||
export const popEndedTouch = function (runtimeScene) {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const popEndedTouch = function (runtimeScene: gdjs.RuntimeScene) {
|
||||
const endedTouchId = runtimeScene
|
||||
.getGame()
|
||||
.getInputManager()
|
||||
|
@@ -28,6 +28,7 @@ namespace gdjs {
|
||||
_releasedMouseButtons: Array<boolean>;
|
||||
_mouseX: float = 0;
|
||||
_mouseY: float = 0;
|
||||
_isMouseInsideCanvas: boolean = true;
|
||||
_mouseWheelDelta: float = 0;
|
||||
_touches: Hashtable<Touch>;
|
||||
//Identifiers of the touches that started during/before the frame.
|
||||
@@ -37,6 +38,15 @@ namespace gdjs {
|
||||
_endedTouches: Array<integer> = [];
|
||||
_touchSimulateMouse: boolean = true;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
_lastStartedTouchIndex = 0;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
_lastEndedTouchIndex = 0;
|
||||
|
||||
constructor() {
|
||||
this._pressedKeys = new Hashtable();
|
||||
this._releasedKeys = new Hashtable();
|
||||
@@ -193,6 +203,27 @@ namespace gdjs {
|
||||
return this._mouseY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the mouse leave the canvas.
|
||||
*/
|
||||
onMouseLeave(): void {
|
||||
this._isMouseInsideCanvas = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called when the mouse enter the canvas.
|
||||
*/
|
||||
onMouseEnter(): void {
|
||||
this._isMouseInsideCanvas = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true when the mouse is inside the canvas.
|
||||
*/
|
||||
isMouseInsideCanvas(): boolean {
|
||||
return this._isMouseInsideCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should be called whenever a mouse button is pressed.
|
||||
* @param buttonCode The mouse button code associated to the event.
|
||||
@@ -254,11 +285,11 @@ namespace gdjs {
|
||||
*
|
||||
* @return the touch X position, relative to the game view.
|
||||
*/
|
||||
getTouchX(identifier: integer): float {
|
||||
if (!this._touches.containsKey(identifier)) {
|
||||
getTouchX(publicIdentifier: integer): float {
|
||||
if (!this._touches.containsKey(publicIdentifier)) {
|
||||
return 0;
|
||||
}
|
||||
return this._touches.get(identifier).x;
|
||||
return this._touches.get(publicIdentifier).x;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,11 +297,19 @@ namespace gdjs {
|
||||
*
|
||||
* @return the touch Y position, relative to the game view.
|
||||
*/
|
||||
getTouchY(identifier: integer): float {
|
||||
if (!this._touches.containsKey(identifier)) {
|
||||
getTouchY(publicIdentifier: integer): float {
|
||||
if (!this._touches.containsKey(publicIdentifier)) {
|
||||
return 0;
|
||||
}
|
||||
return this._touches.get(identifier).y;
|
||||
return this._touches.get(publicIdentifier).y;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param publicIdentifier the touch identifier
|
||||
* @returns true if the touch has just ended.
|
||||
*/
|
||||
hasTouchEnded(publicIdentifier: integer): boolean {
|
||||
return this._endedTouches.includes(publicIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,17 +325,19 @@ namespace gdjs {
|
||||
return InputManager._allTouchIds;
|
||||
}
|
||||
|
||||
onTouchStart(identifier: integer, x: float, y: float): void {
|
||||
this._startedTouches.push(identifier);
|
||||
this._touches.put(identifier, { x: x, y: y, justEnded: false });
|
||||
onTouchStart(rawIdentifier: integer, x: float, y: float): void {
|
||||
const publicIdentifier = this.getPublicTouchIdentifier(rawIdentifier);
|
||||
this._startedTouches.push(publicIdentifier);
|
||||
this._touches.put(publicIdentifier, { x: x, y: y, justEnded: false });
|
||||
if (this._touchSimulateMouse) {
|
||||
this.onMouseMove(x, y);
|
||||
this.onMouseButtonPressed(InputManager.MOUSE_LEFT_BUTTON);
|
||||
}
|
||||
}
|
||||
|
||||
onTouchMove(identifier: integer, x: float, y: float): void {
|
||||
const touch = this._touches.get(identifier);
|
||||
onTouchMove(rawIdentifier: integer, x: float, y: float): void {
|
||||
const publicIdentifier = this.getPublicTouchIdentifier(rawIdentifier);
|
||||
const touch = this._touches.get(publicIdentifier);
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
@@ -307,27 +348,50 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd(identifier: number): void {
|
||||
this._endedTouches.push(identifier);
|
||||
if (this._touches.containsKey(identifier)) {
|
||||
onTouchEnd(rawIdentifier: number): void {
|
||||
const publicIdentifier = this.getPublicTouchIdentifier(rawIdentifier);
|
||||
this._endedTouches.push(publicIdentifier);
|
||||
if (this._touches.containsKey(publicIdentifier)) {
|
||||
//Postpone deletion at the end of the frame
|
||||
this._touches.get(identifier).justEnded = true;
|
||||
this._touches.get(publicIdentifier).justEnded = true;
|
||||
}
|
||||
if (this._touchSimulateMouse) {
|
||||
this.onMouseButtonReleased(InputManager.MOUSE_LEFT_BUTTON);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add 1 to the identifier to avoid identifiers taking
|
||||
* the GDevelop default variable value which is 0.
|
||||
* @param rawIdentifier The identifier given by the browser.
|
||||
* @returns The identifier used in events.
|
||||
*/
|
||||
private getPublicTouchIdentifier(rawIdentifier: integer): integer {
|
||||
return rawIdentifier + 1;
|
||||
}
|
||||
|
||||
getStartedTouchIdentifiers(): integer[] {
|
||||
return this._startedTouches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
popStartedTouch(): integer | undefined {
|
||||
return this._startedTouches.shift();
|
||||
const publicIdentifier = this._startedTouches[
|
||||
this._lastStartedTouchIndex
|
||||
];
|
||||
this._lastStartedTouchIndex++;
|
||||
return publicIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
popEndedTouch(): integer | undefined {
|
||||
return this._endedTouches.shift();
|
||||
const publicIdentifier = this._endedTouches[this._lastEndedTouchIndex];
|
||||
this._lastEndedTouchIndex++;
|
||||
return publicIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,6 +408,13 @@ namespace gdjs {
|
||||
this._touchSimulateMouse = enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the touch events are used to simulate mouse events.
|
||||
*/
|
||||
isSimulatingMouseWithTouch(): boolean {
|
||||
return this._touchSimulateMouse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the input manager that the frame ended, so anything that last
|
||||
* only for one frame (started/ended touches) should be reset.
|
||||
@@ -366,6 +437,8 @@ namespace gdjs {
|
||||
this._releasedKeys.clear();
|
||||
this._releasedMouseButtons.length = 0;
|
||||
this._mouseWheelDelta = 0;
|
||||
this._lastStartedTouchIndex = 0;
|
||||
this._lastEndedTouchIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -81,14 +81,36 @@ namespace gdjs {
|
||||
Number.isInteger(zoomFactor)
|
||||
) {
|
||||
// Camera rounding is important for pixel perfect games.
|
||||
// Otherwise the camera position fractional part is added to
|
||||
// Otherwise, the camera position fractional part is added to
|
||||
// the sprite one and it changes in which direction sprites are rounded.
|
||||
// It makes sprites rounding inconsistent with each other
|
||||
// and they seems to move on pixel left and right.
|
||||
this._pixiContainer.position.x = Math.round(
|
||||
// and they seem to move on pixel left and right.
|
||||
//
|
||||
// PIXI uses a floor function on sprites position on the screen,
|
||||
// so a floor must be applied on the camera position too.
|
||||
// According to the above calculus,
|
||||
// _pixiContainer.position is the opposite of the camera,
|
||||
// this is why the ceil function is used floor(x) = -ceil(-x).
|
||||
//
|
||||
// When the camera directly follows an object,
|
||||
// given this object dimension is even,
|
||||
// the decimal part of onScenePosition and cameraPosition are the same.
|
||||
//
|
||||
// Doing the calculus without rounding:
|
||||
// onScreenPosition = onScenePosition - cameraPosition
|
||||
// onScreenPosition = 980.75 - 200.75
|
||||
// onScreenPosition = 780
|
||||
//
|
||||
// Doing the calculus with rounding:
|
||||
// onScreenPosition = floor(onScenePosition + ceil(-cameraPosition))
|
||||
// onScreenPosition = floor(980.75 + ceil(-200.75))
|
||||
// onScreenPosition = floor(980.75 - 200)
|
||||
// onScreenPosition = floor(780.75)
|
||||
// onScreenPosition = 780
|
||||
this._pixiContainer.position.x = Math.ceil(
|
||||
this._pixiContainer.position.x
|
||||
);
|
||||
this._pixiContainer.position.y = Math.round(
|
||||
this._pixiContainer.position.y = Math.ceil(
|
||||
this._pixiContainer.position.y
|
||||
);
|
||||
}
|
||||
|
@@ -442,7 +442,11 @@ namespace gdjs {
|
||||
/**
|
||||
* Add the standard events handler.
|
||||
*/
|
||||
bindStandardEvents(manager, window, document) {
|
||||
bindStandardEvents(
|
||||
manager: gdjs.InputManager,
|
||||
window: Window,
|
||||
document: Document
|
||||
) {
|
||||
const renderer = this._pixiRenderer;
|
||||
if (!renderer) return;
|
||||
const canvas = renderer.view;
|
||||
@@ -451,7 +455,7 @@ namespace gdjs {
|
||||
//to game coordinates.
|
||||
const that = this;
|
||||
|
||||
function getEventPosition(e) {
|
||||
function getEventPosition(e: MouseEvent | Touch) {
|
||||
const pos = [e.pageX - canvas.offsetLeft, e.pageY - canvas.offsetTop];
|
||||
|
||||
// Handle the fact that the game is stretched to fill the canvas.
|
||||
@@ -462,6 +466,18 @@ namespace gdjs {
|
||||
return pos;
|
||||
}
|
||||
|
||||
function isInsideCanvas(e: MouseEvent | Touch) {
|
||||
const x = e.pageX - canvas.offsetLeft;
|
||||
const y = e.pageY - canvas.offsetTop;
|
||||
|
||||
return (
|
||||
0 <= x &&
|
||||
x < (that._canvasWidth || 1) &&
|
||||
0 <= y &&
|
||||
y < (that._canvasHeight || 1)
|
||||
);
|
||||
}
|
||||
|
||||
//Some browsers lacks definition of some variables used to do calculations
|
||||
//in getEventPosition. They are defined to 0 as they are useless.
|
||||
|
||||
@@ -480,6 +496,7 @@ namespace gdjs {
|
||||
document.documentElement === undefined ||
|
||||
document.documentElement === null
|
||||
) {
|
||||
// @ts-ignore
|
||||
document.documentElement = {};
|
||||
}
|
||||
if (isNaN(document.documentElement.scrollLeft)) {
|
||||
@@ -571,6 +588,12 @@ namespace gdjs {
|
||||
);
|
||||
return false;
|
||||
};
|
||||
canvas.onmouseleave = function (e) {
|
||||
manager.onMouseLeave();
|
||||
};
|
||||
canvas.onmouseenter = function (e) {
|
||||
manager.onMouseEnter();
|
||||
};
|
||||
window.addEventListener(
|
||||
'click',
|
||||
function (e) {
|
||||
@@ -606,6 +629,15 @@ namespace gdjs {
|
||||
for (let i = 0; i < e.changedTouches.length; ++i) {
|
||||
const pos = getEventPosition(e.changedTouches[i]);
|
||||
manager.onTouchMove(e.changedTouches[i].identifier, pos[0], pos[1]);
|
||||
// This works because touch events are sent
|
||||
// when they continue outside of the canvas.
|
||||
if (manager.isSimulatingMouseWithTouch()) {
|
||||
if (isInsideCanvas(e.changedTouches[i])) {
|
||||
manager.onMouseEnter();
|
||||
} else {
|
||||
manager.onMouseLeave();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -55,6 +55,8 @@ namespace gdjs {
|
||||
_scaleMode: 'linear' | 'nearest';
|
||||
_pixelsRounding: boolean;
|
||||
_renderer: RuntimeGameRenderer;
|
||||
_sessionId: string | null;
|
||||
_playerId: string | null;
|
||||
|
||||
//Game loop management (see startGameLoop method)
|
||||
_sceneStack: SceneStack;
|
||||
@@ -120,6 +122,8 @@ namespace gdjs {
|
||||
? new gdjs.DebuggerClient(this)
|
||||
: null;
|
||||
this._isPreview = this._options.isPreview || false;
|
||||
this._sessionId = null;
|
||||
this._playerId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,8 +652,7 @@ namespace gdjs {
|
||||
return;
|
||||
}
|
||||
const baseUrl = 'https://api.gdevelop-app.com/analytics';
|
||||
const playerId = this._makePlayerUuid();
|
||||
let sessionId: string | null = null;
|
||||
this._playerId = this._makePlayerUuid();
|
||||
let lastSessionHitTime = Date.now();
|
||||
fetch(baseUrl + '/session', {
|
||||
method: 'POST',
|
||||
@@ -659,7 +662,7 @@ namespace gdjs {
|
||||
// precisely identify someone.
|
||||
body: JSON.stringify({
|
||||
gameId: this._data.properties.projectUuid,
|
||||
playerId: playerId,
|
||||
playerId: this._playerId,
|
||||
game: {
|
||||
name: this._data.properties.name || '',
|
||||
packageName: this._data.properties.packageName || '',
|
||||
@@ -691,13 +694,13 @@ namespace gdjs {
|
||||
})
|
||||
.then((response) => response.text())
|
||||
.then((returnedSessionId) => {
|
||||
sessionId = returnedSessionId;
|
||||
this._sessionId = returnedSessionId;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
/* Ignore any error */
|
||||
const sendSessionHit = () => {
|
||||
if (!sessionId) {
|
||||
if (!this._sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -711,8 +714,8 @@ namespace gdjs {
|
||||
baseUrl + '/session-hit',
|
||||
JSON.stringify({
|
||||
gameId: this._data.properties.projectUuid,
|
||||
playerId: playerId,
|
||||
sessionId: sessionId,
|
||||
playerId: this._playerId,
|
||||
sessionId: this._sessionId,
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -741,6 +744,7 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
this._sessionMetricsInitialized = true;
|
||||
this._sessionId = this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -762,6 +766,14 @@ namespace gdjs {
|
||||
}
|
||||
}
|
||||
|
||||
getSessionId(): string | null {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
getPlayerId(): string | null {
|
||||
return this._playerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the game renderer when the window containing the game
|
||||
* has changed size (this can result from a resize of the window,
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -31,6 +31,7 @@ const untransformedPaths = [
|
||||
'GDJS/Runtime/libs/rbush.js',
|
||||
|
||||
// Extensions pre-built files:
|
||||
'Extensions/Leaderboards/sha256.js',
|
||||
'Extensions/Firebase/A_firebasejs',
|
||||
'Extensions/BBText/pixi-multistyle-text/dist',
|
||||
'Extensions/DialogueTree/bondage.js/dist',
|
||||
|
@@ -75,6 +75,7 @@ module.exports = function (config) {
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/LinkedObjects/linkedobjects.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventory.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Inventory/inventorytools.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Leaderboards/leaderboardstools.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightruntimeobject-pixi-renderer.js',
|
||||
'../../newIDE/app/resources/GDJS/Runtime/Extensions/Lighting/lightobstacleruntimebehavior.js',
|
||||
|
@@ -3,7 +3,26 @@
|
||||
*/
|
||||
|
||||
describe('gdjs.InputManager', function() {
|
||||
var inputManager = new gdjs.InputManager();
|
||||
|
||||
const runtimeGame = new gdjs.RuntimeGame({
|
||||
variables: [],
|
||||
resources: {
|
||||
resources: [],
|
||||
},
|
||||
properties: { windowWidth: 800, windowHeight: 600 },
|
||||
});
|
||||
const runtimeScene = new gdjs.RuntimeScene(runtimeGame);
|
||||
runtimeScene.loadFromScene({
|
||||
layers: [{ name: '', visibility: true, effects: [] }],
|
||||
variables: [],
|
||||
behaviorsSharedData: [],
|
||||
objects: [],
|
||||
instances: [],
|
||||
});
|
||||
const inputManager = runtimeScene
|
||||
.getGame()
|
||||
.getInputManager();
|
||||
const inputTools = gdjs.evtTools.input;
|
||||
|
||||
it('should handle keyboards events', function() {
|
||||
expect(inputManager.anyKeyPressed()).to.be(false);
|
||||
@@ -90,17 +109,98 @@ describe('gdjs.InputManager', function() {
|
||||
expect(
|
||||
inputManager.isMouseButtonReleased(gdjs.InputManager.MOUSE_LEFT_BUTTON)
|
||||
).to.be(false);
|
||||
|
||||
expect(inputManager.isMouseInsideCanvas()).to.be(true);
|
||||
inputManager.onMouseLeave();
|
||||
expect(inputManager.isMouseInsideCanvas()).to.be(false);
|
||||
inputManager.onMouseEnter();
|
||||
expect(inputManager.isMouseInsideCanvas()).to.be(true);
|
||||
});
|
||||
|
||||
it('should handle touch events', function() {
|
||||
inputManager.onTouchStart(46, 510, 610);
|
||||
inputManager.onTouchStart(10, 510, 610);
|
||||
expect(inputManager.getStartedTouchIdentifiers()).to.have.length(2);
|
||||
expect(inputManager.getTouchX(46)).to.be(510);
|
||||
expect(inputManager.getTouchY(46)).to.be(610);
|
||||
inputManager.onTouchStart(10, 470, 320);
|
||||
|
||||
expect(inputManager.popStartedTouch()).to.be(46);
|
||||
expect(inputManager.popStartedTouch()).to.be(10);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getStartedTouchCount(runtimeScene)).to.be(2);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 0)).to.be(47);
|
||||
expect(inputTools.getTouchX(runtimeScene, 47)).to.be(510);
|
||||
expect(inputTools.getTouchY(runtimeScene, 47)).to.be(610);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 1)).to.be(11);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
// Events can ask touches again
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getStartedTouchCount(runtimeScene)).to.be(2);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
inputManager.onTouchEnd(10);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 11)).to.be(true);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 47)).to.be(false);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(false);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(1);
|
||||
});
|
||||
|
||||
it('should handle legacy and new touch events without any conflict', function() {
|
||||
inputManager.onTouchStart(46, 510, 610);
|
||||
inputManager.onTouchStart(10, 470, 320);
|
||||
|
||||
// legacy ones
|
||||
expect(inputTools.popStartedTouch(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getLastTouchId(runtimeScene)).to.be(47);
|
||||
expect(inputTools.getTouchX(runtimeScene, 47)).to.be(510);
|
||||
expect(inputTools.getTouchY(runtimeScene, 47)).to.be(610);
|
||||
expect(inputTools.popStartedTouch(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getLastTouchId(runtimeScene)).to.be(11);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene)).to.be(false);
|
||||
|
||||
// new ones
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getStartedTouchCount(runtimeScene)).to.be(2);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 0)).to.be(47);
|
||||
expect(inputTools.getTouchX(runtimeScene, 47)).to.be(510);
|
||||
expect(inputTools.getTouchY(runtimeScene, 47)).to.be(610);
|
||||
expect(inputTools.getStartedTouchIdentifier(runtimeScene, 1)).to.be(11);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
inputManager.onTouchEnd(10);
|
||||
|
||||
// legacy ones
|
||||
expect(inputTools.popEndedTouch(runtimeScene)).to.be(true);
|
||||
expect(inputTools.getLastEndedTouchId()).to.be(11);
|
||||
expect(inputTools.popEndedTouch(runtimeScene)).to.be(false);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(false);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
// new ones
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 11)).to.be(true);
|
||||
expect(inputTools.hasTouchEnded(runtimeScene, 47)).to.be(false);
|
||||
expect(inputTools.hasAnyTouchStarted(runtimeScene)).to.be(false);
|
||||
expect(inputTools.getTouchX(runtimeScene, 11)).to.be(470);
|
||||
expect(inputTools.getTouchY(runtimeScene, 11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(1);
|
||||
});
|
||||
|
||||
it('should handle deprecated touch events', function() {
|
||||
inputManager.onTouchStart(46, 510, 610);
|
||||
inputManager.onTouchStart(10, 470, 320);
|
||||
expect(inputManager.getStartedTouchIdentifiers()).to.have.length(2);
|
||||
expect(inputManager.getTouchX(47)).to.be(510);
|
||||
expect(inputManager.getTouchY(47)).to.be(610);
|
||||
|
||||
expect(inputManager.popStartedTouch()).to.be(47);
|
||||
expect(inputManager.popStartedTouch()).to.be(11);
|
||||
expect(inputManager.popEndedTouch()).to.be(undefined);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
@@ -108,13 +208,14 @@ describe('gdjs.InputManager', function() {
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(2);
|
||||
expect(inputManager.getStartedTouchIdentifiers()).to.have.length(0);
|
||||
expect(inputManager.popStartedTouch()).to.be(undefined);
|
||||
expect(inputManager.popEndedTouch()).to.be(10);
|
||||
expect(inputManager.getTouchX(10)).to.be(510);
|
||||
expect(inputManager.getTouchY(10)).to.be(610);
|
||||
expect(inputManager.popEndedTouch()).to.be(11);
|
||||
expect(inputManager.getTouchX(11)).to.be(470);
|
||||
expect(inputManager.getTouchY(11)).to.be(320);
|
||||
|
||||
inputManager.onFrameEnded();
|
||||
expect(inputManager.getAllTouchIdentifiers()).to.have.length(1);
|
||||
});
|
||||
|
||||
it('should simulate (or not) mouse events', function() {
|
||||
inputManager.touchSimulateMouse();
|
||||
expect(inputManager.isMouseButtonPressed(0)).to.be(false);
|
||||
|
@@ -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);
|
||||
|
||||
|
22
GDevelop.js/package-lock.json
generated
22
GDevelop.js/package-lock.json
generated
@@ -1214,9 +1214,9 @@
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.15",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
|
||||
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"parse5": {
|
||||
@@ -8019,8 +8019,8 @@
|
||||
"dev": true
|
||||
},
|
||||
"webidl-tools": {
|
||||
"version": "git://github.com/4ian/webidl-tools.git#87247d37944d9cfdecb4f73da93289929b4077df",
|
||||
"from": "git://github.com/4ian/webidl-tools.git#87247d37944d9cfdecb4f73da93289929b4077df",
|
||||
"version": "github:4ian/webidl-tools#348f9c03afc9d8f278efccdd74543e265a41fd11",
|
||||
"from": "github:4ian/webidl-tools#348f9c03afc9d8f278efccdd74543e265a41fd11",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cheerio": "^0.20.0",
|
||||
@@ -8032,13 +8032,13 @@
|
||||
"stream-concat": "^0.1.0",
|
||||
"vinyl": "^1.1.1",
|
||||
"vinyl-fs": "^2.4.2",
|
||||
"webidl2": "git://github.com/markandrus/webidl2.js.git#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"webidl2": "github:markandrus/webidl2.js#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"winston": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"webidl2": {
|
||||
"version": "git://github.com/markandrus/webidl2.js.git#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"from": "git://github.com/markandrus/webidl2.js.git#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"version": "github:markandrus/webidl2.js#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"from": "github:markandrus/webidl2.js#e470735423d73fbbc20d472d9e0174592b80a463",
|
||||
"dev": true
|
||||
},
|
||||
"whatwg-encoding": {
|
||||
@@ -8129,9 +8129,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"winston": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-2.4.4.tgz",
|
||||
"integrity": "sha512-NBo2Pepn4hK4V01UfcWcDlmiVTs7VTB1h7bgnB0rgP146bYhMxX0ypCz3lBOfNxCO4Zuek7yeT+y/zM1OfMw4Q==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-2.4.5.tgz",
|
||||
"integrity": "sha512-TWoamHt5yYvsMarGlGEQE59SbJHqGsZV8/lwC+iCcGeAe0vUaOh+Lv6SYM17ouzC/a/LB1/hz/7sxFBtlu1l4A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"async": "~1.0.0",
|
||||
|
@@ -33,7 +33,7 @@
|
||||
"grunt-string-replace": "^1.3.1",
|
||||
"jest": "^23.5.0",
|
||||
"shelljs": "^0.8.4",
|
||||
"webidl-tools": "git://github.com/4ian/webidl-tools.git#87247d37944d9cfdecb4f73da93289929b4077df"
|
||||
"webidl-tools": "github:4ian/webidl-tools#348f9c03afc9d8f278efccdd74543e265a41fd11"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
45
newIDE/app/package-lock.json
generated
45
newIDE/app/package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@blueprintjs/core": "file:src/Utils/BlueprintJsPlaceholder",
|
||||
"@blueprintjs/icons": "file:src/Utils/BlueprintJsPlaceholder",
|
||||
"@lingui/react": "git://github.com/4ian/lingui-react.git#master",
|
||||
"@lingui/react": "github:4ian/lingui-react#master",
|
||||
"@material-ui/core": "4.11.0",
|
||||
"@material-ui/icons": "4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.56",
|
||||
@@ -32,7 +32,7 @@
|
||||
"keen-tracking": "1.1.3",
|
||||
"lodash": "4.17.4",
|
||||
"node-require-function": "^1.2.0",
|
||||
"pixi-simple-gesture": "git://github.com/4ian/pixi-simple-gesture#v0.3.3",
|
||||
"pixi-simple-gesture": "github:4ian/pixi-simple-gesture#v0.3.3",
|
||||
"pixi.js-legacy": "^6.1.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"randomcolor": "^0.5.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"react-markdown": "^4.0.6",
|
||||
"react-measure": "2.3.0",
|
||||
"react-monaco-editor": "^0.18.0",
|
||||
"react-mosaic-component": "git://github.com/4ian/react-mosaic#v3.1.0",
|
||||
"react-mosaic-component": "github:4ian/react-mosaic#v3.1.0",
|
||||
"react-share": "^4.4.0",
|
||||
"react-sortable-hoc": "1.5.0",
|
||||
"react-sortable-tree": "2.6.2",
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
7
newIDE/app/public/JsPlatform/Extensions/leaderboard.svg
Normal file
7
newIDE/app/public/JsPlatform/Extensions/leaderboard.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="62" height="62" viewBox="0 0 62 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M47.1917 9.52621C61.6331 3.903 62.0217 29.4536 45.9739 23.6472L47.1917 9.52621Z" stroke="#2B3990" stroke-width="5"/>
|
||||
<path d="M14.8011 9.96676C0.361326 4.34356 -0.0272331 29.8942 16.0187 24.0878L14.8011 9.96676Z" stroke="#2B3990" stroke-width="5"/>
|
||||
<path d="M18.2426 30.1975C12.1426 23.6797 13.7746 11.1717 14.4872 4.10922H32.3022H47.9793C47.9793 4.10922 49.7339 23.6185 44.2953 30.1975C38.8567 36.7765 23.1267 35.4161 18.2426 30.1975Z" fill="#27AAE1" stroke="#2B3990" stroke-width="5"/>
|
||||
<path d="M31.175 35.033C27.3891 35.033 31.175 35.033 25.1917 57.6229H37.2175C31.175 35.033 34.9609 35.033 31.175 35.033Z" fill="#27AAE1" stroke="#2B3990" stroke-width="5"/>
|
||||
<rect x="18.6728" y="55.1399" width="25.0635" height="6.3261" rx="3" fill="#2B3990"/>
|
||||
</svg>
|
After Width: | Height: | Size: 859 B |
@@ -23,6 +23,7 @@ import { IconContainer } from '../../UI/IconContainer';
|
||||
import { UserPublicProfileChip } from '../../UI/User/UserPublicProfileChip';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import Window from '../../Utils/Window';
|
||||
import { useExtensionUpdate } from './UseExtensionUpdates';
|
||||
|
||||
const getTransformedDescription = (extensionHeader: ExtensionHeader) => {
|
||||
if (
|
||||
@@ -45,7 +46,7 @@ type Props = {|
|
||||
onClose: () => void,
|
||||
onInstall: () => Promise<void>,
|
||||
onEdit?: () => void,
|
||||
alreadyInstalled: boolean,
|
||||
project: gdProject,
|
||||
|};
|
||||
|
||||
const ExtensionInstallDialog = ({
|
||||
@@ -54,8 +55,13 @@ const ExtensionInstallDialog = ({
|
||||
onClose,
|
||||
onInstall,
|
||||
onEdit,
|
||||
alreadyInstalled,
|
||||
project,
|
||||
}: Props) => {
|
||||
const alreadyInstalled = project.hasEventsFunctionsExtensionNamed(
|
||||
extensionShortHeader.name
|
||||
);
|
||||
const extensionUpdate = useExtensionUpdate(project, extensionShortHeader);
|
||||
|
||||
const [error, setError] = React.useState<?Error>(null);
|
||||
const [
|
||||
extensionHeader,
|
||||
@@ -118,7 +124,11 @@ const ExtensionInstallDialog = ({
|
||||
!isCompatible ? (
|
||||
<Trans>Not compatible</Trans>
|
||||
) : alreadyInstalled ? (
|
||||
<Trans>Re-install/update</Trans>
|
||||
extensionUpdate ? (
|
||||
<Trans>Update</Trans>
|
||||
) : (
|
||||
<Trans>Re-install</Trans>
|
||||
)
|
||||
) : (
|
||||
<Trans>Install in project</Trans>
|
||||
)
|
||||
|
@@ -0,0 +1,58 @@
|
||||
//@flow
|
||||
import { diff } from 'semver/functions/diff';
|
||||
import { useMemo } from 'react';
|
||||
import type { ExtensionShortHeader } from '../../Utils/GDevelopServices/Extension';
|
||||
|
||||
type UpdateType = 'patch' | 'minor' | 'major';
|
||||
type UpdateMetadata = {|
|
||||
type: UpdateType,
|
||||
currentVersion: string,
|
||||
newestVersion: string,
|
||||
|};
|
||||
|
||||
const getUpdateMetadataFromVersions = (
|
||||
currentVersion: string,
|
||||
newestVersion: string
|
||||
): UpdateMetadata | null => {
|
||||
try {
|
||||
const versionDiff: UpdateType = diff(currentVersion, newestVersion);
|
||||
if (['patch', 'minor', 'major'].includes(versionDiff)) {
|
||||
return {
|
||||
type: versionDiff,
|
||||
currentVersion,
|
||||
newestVersion,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// An error will be thrown here only if the version is not in semver.
|
||||
// Simply compare the strings for such extensions.
|
||||
// Note that this is an edge case, the extension repository enforces semver, so this
|
||||
// is only for local extensions that do not respect the best practices.
|
||||
if (currentVersion !== newestVersion) {
|
||||
return {
|
||||
// Use minor as it is the most neutral option
|
||||
type: 'minor',
|
||||
currentVersion,
|
||||
newestVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useExtensionUpdate = (
|
||||
project: gdProject,
|
||||
extension: ExtensionShortHeader
|
||||
): UpdateMetadata | null => {
|
||||
return useMemo<UpdateMetadata | null>(
|
||||
() =>
|
||||
project.hasEventsFunctionsExtensionNamed(extension.name)
|
||||
? getUpdateMetadataFromVersions(
|
||||
project.getEventsFunctionsExtension(extension.name).getVersion(),
|
||||
extension.version
|
||||
)
|
||||
: null,
|
||||
[project, extension]
|
||||
);
|
||||
};
|
@@ -121,11 +121,9 @@ export const ExtensionStore = ({
|
||||
</ResponsiveWindowMeasurer>
|
||||
{!!selectedExtensionShortHeader && (
|
||||
<ExtensionInstallDialog
|
||||
project={project}
|
||||
isInstalling={isInstalling}
|
||||
extensionShortHeader={selectedExtensionShortHeader}
|
||||
alreadyInstalled={project.hasEventsFunctionsExtensionNamed(
|
||||
selectedExtensionShortHeader.name
|
||||
)}
|
||||
onInstall={async () => {
|
||||
const wasInstalled = await onInstall(selectedExtensionShortHeader);
|
||||
if (wasInstalled) setSelectedExtensionShortHeader(null);
|
||||
|
@@ -71,6 +71,7 @@ export const create = (authentication: Authentication) => {
|
||||
renderExportDialog={props => (
|
||||
<ExportDialog
|
||||
project={props.project}
|
||||
onSaveProject={props.onSaveProject}
|
||||
onChangeSubscription={props.onChangeSubscription}
|
||||
onClose={props.onClose}
|
||||
automatedExporters={browserAutomatedExporters}
|
||||
|
@@ -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...`,
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -343,7 +343,7 @@ export default function NewInstructionEditorDialog({
|
||||
cannotBeDismissed={true}
|
||||
maxWidth={false}
|
||||
noMargin
|
||||
flexRowBody
|
||||
flexBody
|
||||
fullHeight={
|
||||
true /* Always use full height to avoid a very small dialog when there are not a lot of objects. */
|
||||
}
|
||||
|
231
newIDE/app/src/EventsSheet/ParameterFields/LeaderboardIdField.js
Normal file
231
newIDE/app/src/EventsSheet/ParameterFields/LeaderboardIdField.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import OpenInNew from '@material-ui/icons/OpenInNew';
|
||||
import { type ParameterInlineRendererProps } from './ParameterInlineRenderer.flow';
|
||||
import {
|
||||
type ParameterFieldProps,
|
||||
type ParameterFieldInterface,
|
||||
} from './ParameterFieldCommons';
|
||||
import SelectField from '../../UI/SelectField';
|
||||
import SelectOption from '../../UI/SelectOption';
|
||||
import { TextFieldWithButtonLayout } from '../../UI/Layout';
|
||||
import RaisedButtonWithSplitMenu from '../../UI/RaisedButtonWithSplitMenu';
|
||||
import { type Leaderboard } from '../../Utils/GDevelopServices/Play';
|
||||
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
|
||||
import LeaderboardDialog from '../../Leaderboard/LeaderboardDialog';
|
||||
import GenericExpressionField from './GenericExpressionField';
|
||||
import { breakUuid } from '../../Utils/GDevelopServices/Play';
|
||||
import { useOnlineStatus } from '../../Utils/OnlineStatus';
|
||||
|
||||
const getInlineParameterDisplayValue = (
|
||||
leaderboards: ?Array<Leaderboard>,
|
||||
value: string
|
||||
): string => {
|
||||
if (!leaderboards) return value;
|
||||
const leaderboard = leaderboards.find(
|
||||
leaderboard => `"${leaderboard.id}"` === value
|
||||
);
|
||||
return leaderboard ? leaderboard.name : value;
|
||||
};
|
||||
|
||||
const useFetchLeaderboards = () => {
|
||||
const { leaderboards, listLeaderboards } = React.useContext(
|
||||
LeaderboardContext
|
||||
);
|
||||
const fetchLeaderboards = React.useCallback(
|
||||
async () => {
|
||||
await listLeaderboards();
|
||||
},
|
||||
[listLeaderboards]
|
||||
);
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!leaderboards) {
|
||||
fetchLeaderboards();
|
||||
}
|
||||
},
|
||||
[fetchLeaderboards, leaderboards]
|
||||
);
|
||||
|
||||
return leaderboards;
|
||||
};
|
||||
|
||||
export default React.forwardRef<ParameterFieldProps, ParameterFieldInterface>(
|
||||
function LeaderboardIdField(props, ref) {
|
||||
const isOnline = useOnlineStatus();
|
||||
const leaderboards = useFetchLeaderboards();
|
||||
const [isAdminOpen, setIsAdminOpen] = React.useState(false);
|
||||
const inputFieldRef = React.useRef<?(GenericExpressionField | SelectField)>(
|
||||
null
|
||||
);
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.focus();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const isCurrentValueInLeaderboardList =
|
||||
leaderboards &&
|
||||
!!leaderboards.find(leaderboard => `"${leaderboard.id}"` === props.value);
|
||||
|
||||
const [isExpressionField, setIsExpressionField] = React.useState(
|
||||
!leaderboards || (!!props.value && !isCurrentValueInLeaderboardList)
|
||||
);
|
||||
|
||||
const onChangeSelectValue = (event, value) => {
|
||||
props.onChange(event.target.value);
|
||||
};
|
||||
|
||||
const onChangeTextValue = (value: string) => {
|
||||
props.onChange(value);
|
||||
};
|
||||
|
||||
const fieldLabel = props.parameterMetadata
|
||||
? props.parameterMetadata.getDescription()
|
||||
: undefined;
|
||||
|
||||
const gameHasLeaderboards = leaderboards && leaderboards.length > 0;
|
||||
|
||||
const selectOptions = React.useMemo(
|
||||
() =>
|
||||
leaderboards && gameHasLeaderboards
|
||||
? leaderboards.map(leaderboard => (
|
||||
<SelectOption
|
||||
key={leaderboard.id}
|
||||
value={`"${leaderboard.id}"`}
|
||||
primaryText={`${leaderboard.name} ${
|
||||
leaderboard.id ? `(${breakUuid(leaderboard.id)})` : ''
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
: [
|
||||
<SelectOption
|
||||
disabled
|
||||
key="empty"
|
||||
value="empty"
|
||||
primaryText={''}
|
||||
/>,
|
||||
],
|
||||
[leaderboards, gameHasLeaderboards]
|
||||
);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<>
|
||||
<TextFieldWithButtonLayout
|
||||
renderTextField={() =>
|
||||
!isExpressionField ? (
|
||||
<SelectField
|
||||
ref={inputFieldRef}
|
||||
value={props.value}
|
||||
onChange={onChangeSelectValue}
|
||||
margin={props.isInline ? 'none' : 'dense'}
|
||||
fullWidth
|
||||
floatingLabelText={fieldLabel}
|
||||
hintText={
|
||||
gameHasLeaderboards
|
||||
? props.parameterMetadata &&
|
||||
props.parameterMetadata.isOptional()
|
||||
? t`Choose a leaderboard (optional)`
|
||||
: t`Choose a leaderboard`
|
||||
: t`No leaderboards`
|
||||
}
|
||||
helperMarkdownText={
|
||||
!gameHasLeaderboards
|
||||
? i18n._(
|
||||
t`There are currently no leaderboards created for this game. Open the leaderboards manager to create one.`
|
||||
)
|
||||
: (props.parameterMetadata &&
|
||||
props.parameterMetadata.getLongDescription()) ||
|
||||
null
|
||||
}
|
||||
>
|
||||
{selectOptions}
|
||||
</SelectField>
|
||||
) : (
|
||||
<GenericExpressionField
|
||||
ref={inputFieldRef}
|
||||
expressionType="string"
|
||||
{...props}
|
||||
onChange={onChangeTextValue}
|
||||
onExtractAdditionalErrors={(
|
||||
currentExpression: string,
|
||||
currentExpressionNode: gdExpressionNode
|
||||
) => {
|
||||
if (!leaderboards) {
|
||||
if (!isOnline)
|
||||
return 'Unable to fetch leaderboards as you are offline.';
|
||||
return 'Your game may not be registered, create one in the leaderboard manager.';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
renderButton={style => (
|
||||
<RaisedButtonWithSplitMenu
|
||||
icon={<OpenInNew />}
|
||||
style={style}
|
||||
primary
|
||||
onClick={() => setIsAdminOpen(true)}
|
||||
buildMenuTemplate={i18n => [
|
||||
{
|
||||
label: isExpressionField
|
||||
? i18n._(t`Select the leaderboard from a list`)
|
||||
: i18n._(
|
||||
t`Enter the leaderboard id as a text or an expression`
|
||||
),
|
||||
disabled: !leaderboards,
|
||||
click: () => setIsExpressionField(!isExpressionField),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isAdminOpen && !!props.project && (
|
||||
<LeaderboardDialog
|
||||
onClose={() => setIsAdminOpen(false)}
|
||||
open={isAdminOpen}
|
||||
project={props.project}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const InlineLeaderboardIdField = ({
|
||||
value,
|
||||
parameterMetadata,
|
||||
InvalidParameterValue,
|
||||
}: ParameterInlineRendererProps) => {
|
||||
const leaderboards = useFetchLeaderboards();
|
||||
|
||||
if (!value) {
|
||||
if (parameterMetadata.isOptional()) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>No leaderboard chosen</Trans>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<InvalidParameterValue isEmpty>
|
||||
<Trans>Choose a leaderboard</Trans>
|
||||
</InvalidParameterValue>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span>{getInlineParameterDisplayValue(leaderboards, value)}</span>;
|
||||
};
|
||||
|
||||
export const renderInlineLeaderboardIdField = (
|
||||
props: ParameterInlineRendererProps
|
||||
) => <InlineLeaderboardIdField {...props} />;
|
@@ -56,6 +56,9 @@ import ObjectAnimationNameField from './ParameterFields/ObjectAnimationNameField
|
||||
import FunctionParameterNameField from './ParameterFields/FunctionParameterNameField';
|
||||
import ExternalLayoutNameField from './ParameterFields/ExternalLayoutNameField';
|
||||
import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow';
|
||||
import LeaderboardIdField, {
|
||||
renderInlineLeaderboardIdField,
|
||||
} from './ParameterFields/LeaderboardIdField';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const components = {
|
||||
@@ -95,6 +98,7 @@ const components = {
|
||||
objectAnimationName: ObjectAnimationNameField,
|
||||
functionParameterName: FunctionParameterNameField,
|
||||
externalLayoutName: ExternalLayoutNameField,
|
||||
leaderboardId: LeaderboardIdField,
|
||||
};
|
||||
const inlineRenderers: { [string]: ParameterInlineRenderer } = {
|
||||
default: renderInlineDefaultField,
|
||||
@@ -109,6 +113,7 @@ const inlineRenderers: { [string]: ParameterInlineRenderer } = {
|
||||
trueorfalse: renderInlineTrueFalse,
|
||||
operator: renderInlineOperator,
|
||||
relationalOperator: renderInlineRelationalOperator,
|
||||
leaderboardId: renderInlineLeaderboardIdField,
|
||||
};
|
||||
const userFriendlyTypeName: { [string]: MessageDescriptor } = {
|
||||
mouse: t`Mouse button`,
|
||||
|
@@ -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 {
|
||||
|
@@ -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,
|
||||
|
@@ -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(
|
||||
() =>
|
||||
|
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
@@ -24,9 +24,8 @@ import {
|
||||
} from '../ExportPipeline.flow';
|
||||
import {
|
||||
ExplanationHeader,
|
||||
WebProjectLink,
|
||||
OnlineGameLink,
|
||||
} from '../GenericExporters/OnlineWebExport';
|
||||
import { type BuildStep } from '../Builds/BuildStepsProgress';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
type ExportState = null;
|
||||
@@ -73,17 +72,19 @@ export const browserOnlineWebExportPipeline: ExportPipeline<
|
||||
|
||||
renderLaunchButtonLabel: () => <Trans>Generate link</Trans>,
|
||||
|
||||
renderCustomStepsProgress: (
|
||||
build: ?Build,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string
|
||||
) => (
|
||||
<WebProjectLink
|
||||
renderCustomStepsProgress: ({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
}) => (
|
||||
<OnlineGameLink
|
||||
build={build}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
errored={errored}
|
||||
exportStep={exportStep}
|
||||
getGameThumbnailUrl={getGameThumbnailUrl}
|
||||
/>
|
||||
),
|
||||
|
||||
|
@@ -216,14 +216,14 @@ export default ({
|
||||
<Line expand justifyContent="flex-end">
|
||||
{game && !!build.s3Key && !isBuildPublished && (
|
||||
<RaisedButton
|
||||
label={<Trans>Publish this build on Liluo</Trans>}
|
||||
label={<Trans>Publish this build on Liluo.io</Trans>}
|
||||
onClick={() => onUpdatePublicBuild(build.id)}
|
||||
disabled={gameUpdating}
|
||||
/>
|
||||
)}
|
||||
{game && !!build.s3Key && isBuildPublished && (
|
||||
<FlatButton
|
||||
label={<Trans>Unpublish this build from Liluo</Trans>}
|
||||
label={<Trans>Unpublish this build from Liluo.io</Trans>}
|
||||
onClick={() => onUpdatePublicBuild(null)}
|
||||
disabled={gameUpdating}
|
||||
/>
|
||||
|
@@ -41,6 +41,7 @@ type ExportHomeProps = {|
|
||||
setChosenExporterSection: (section: ExporterSection) => void,
|
||||
cantExportBecauseOffline: boolean,
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
onChangeSubscription: () => void,
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
isNavigationDisabled: boolean,
|
||||
@@ -54,6 +55,7 @@ const ExportHome = ({
|
||||
setChosenExporterSection,
|
||||
cantExportBecauseOffline,
|
||||
project,
|
||||
onSaveProject,
|
||||
onChangeSubscription,
|
||||
authenticatedUser,
|
||||
isNavigationDisabled,
|
||||
@@ -77,6 +79,7 @@ const ExportHome = ({
|
||||
<ExportLauncher
|
||||
exportPipeline={onlineWebExporter.exportPipeline}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
onChangeSubscription={onChangeSubscription}
|
||||
authenticatedUser={authenticatedUser}
|
||||
setIsNavigationDisabled={setIsNavigationDisabled}
|
||||
|
@@ -23,7 +23,6 @@ import BuildStepsProgress, {
|
||||
import {
|
||||
registerGame,
|
||||
getGame,
|
||||
updateGame,
|
||||
type Game,
|
||||
setGameUserAcls,
|
||||
getAclsFromUserIds,
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
addCreateBadgePreHookIfNotClaimed,
|
||||
TRIVIAL_FIRST_WEB_EXPORT,
|
||||
} from '../../Utils/GDevelopServices/Badge';
|
||||
import { getWebBuildThumbnailUrl } from '../../Utils/GDevelopServices/Build';
|
||||
|
||||
type State = {|
|
||||
exportStep: BuildStep,
|
||||
@@ -50,6 +48,7 @@ type State = {|
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
onChangeSubscription: () => void,
|
||||
authenticatedUser: AuthenticatedUser,
|
||||
exportPipeline: ExportPipeline<any, any, any, any, any>,
|
||||
@@ -157,30 +156,22 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
registerAndUpdateGame = async () => {
|
||||
registerGameIfNot = async () => {
|
||||
const profile = this.props.authenticatedUser.profile;
|
||||
const getAuthorizationHeader = this.props.authenticatedUser
|
||||
.getAuthorizationHeader;
|
||||
const gameId = this.props.project.getProjectUuid();
|
||||
const authorName =
|
||||
this.props.project.getAuthor() || 'Unspecified publisher';
|
||||
const gameName = this.props.project.getName() || 'Untitled game';
|
||||
if (profile) {
|
||||
const userId = profile.id;
|
||||
try {
|
||||
// Try to fetch the game to see if it's registered.
|
||||
// Try to fetch the game to see if it's registered but do not do anything with it.
|
||||
await getGame(getAuthorizationHeader, userId, gameId);
|
||||
// Update the game details to ensure that it is up to date in GDevelop services.
|
||||
const game = await updateGame(getAuthorizationHeader, userId, gameId, {
|
||||
authorName,
|
||||
gameName,
|
||||
});
|
||||
// We don't await for the authors update, as it is not required for publishing.
|
||||
this.tryUpdateAuthors();
|
||||
this.props.onGameUpdated(game);
|
||||
} catch (err) {
|
||||
if (err.response.status === 404) {
|
||||
// If the game is not registered, register it before launching the export.
|
||||
const authorName =
|
||||
this.props.project.getAuthor() || 'Unspecified publisher';
|
||||
const gameName = this.props.project.getName() || 'Untitled game';
|
||||
const game = await registerGame(getAuthorizationHeader, userId, {
|
||||
gameId,
|
||||
authorName,
|
||||
@@ -253,7 +244,7 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
try {
|
||||
setStep('register');
|
||||
// We await for this call, allowing to link the build to the game just registered.
|
||||
await this.registerAndUpdateGame();
|
||||
await this.registerGameIfNot();
|
||||
} catch {
|
||||
// But if it fails, we don't prevent building the game.
|
||||
console.warn('Error while registering the game - ignoring it.');
|
||||
@@ -342,7 +333,12 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
doneFooterOpen,
|
||||
exportState,
|
||||
} = this.state;
|
||||
const { project, authenticatedUser, exportPipeline } = this.props;
|
||||
const {
|
||||
project,
|
||||
authenticatedUser,
|
||||
exportPipeline,
|
||||
onSaveProject,
|
||||
} = this.props;
|
||||
if (!project) return null;
|
||||
const getBuildLimit = (authenticatedUser: AuthenticatedUser): ?Limit =>
|
||||
authenticatedUser.limits && exportPipeline.onlineBuildType
|
||||
@@ -411,14 +407,13 @@ export default class ExportLauncher extends Component<Props, State> {
|
||||
)}
|
||||
{authenticatedUser.authenticated &&
|
||||
(exportPipeline.renderCustomStepsProgress ? (
|
||||
exportPipeline.renderCustomStepsProgress(
|
||||
exportPipeline.renderCustomStepsProgress({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
buildId =>
|
||||
this.props.project &&
|
||||
getWebBuildThumbnailUrl(this.props.project, buildId)
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<Line expand>
|
||||
<BuildStepsProgress
|
||||
|
@@ -43,6 +43,7 @@ export type Exporter = {|
|
||||
|
||||
export type ExportDialogWithoutExportsProps = {|
|
||||
project: ?gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
onClose: () => void,
|
||||
onChangeSubscription: () => void,
|
||||
|};
|
||||
@@ -57,6 +58,7 @@ type Props = {|
|
||||
|
||||
const ExportDialog = ({
|
||||
project,
|
||||
onSaveProject,
|
||||
onClose,
|
||||
allExportersRequireOnline,
|
||||
onChangeSubscription,
|
||||
@@ -197,6 +199,7 @@ const ExportDialog = ({
|
||||
setChosenExporterKey={setChosenExporterKey}
|
||||
setChosenExporterSection={setChosenExporterSection}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
onChangeSubscription={onChangeSubscription}
|
||||
authenticatedUser={authenticatedUser}
|
||||
isNavigationDisabled={isNavigationDisabled}
|
||||
@@ -233,6 +236,7 @@ const ExportDialog = ({
|
||||
<ExportLauncher
|
||||
exportPipeline={exporter.exportPipeline}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
onChangeSubscription={onChangeSubscription}
|
||||
authenticatedUser={authenticatedUser}
|
||||
key={chosenExporterKey}
|
||||
|
@@ -46,12 +46,13 @@ export type ExportPipeline<
|
||||
|
||||
isNavigationDisabled: (exportStep: BuildStep, errored: boolean) => boolean,
|
||||
|
||||
renderCustomStepsProgress?: (
|
||||
renderCustomStepsProgress?: ({
|
||||
build: ?Build,
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string
|
||||
) => React.Node,
|
||||
}) => React.Node,
|
||||
|
||||
prepareExporter: (
|
||||
context: ExportPipelineContext<ExportState>
|
||||
|
@@ -1,345 +0,0 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { t } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import Text from '../../UI/Text';
|
||||
import { Column, Line } from '../../UI/Grid';
|
||||
import TextField from '../../UI/TextField';
|
||||
import {
|
||||
getBuildArtifactUrl,
|
||||
type Build,
|
||||
} from '../../Utils/GDevelopServices/Build';
|
||||
import { type BuildStep } from '../Builds/BuildStepsProgress';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import Window from '../../Utils/Window';
|
||||
import Copy from '../../UI/CustomSvgIcons/Copy';
|
||||
import Share from '@material-ui/icons/Share';
|
||||
import InfoBar from '../../UI/Messages/InfoBar';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import { CircularProgress, LinearProgress } from '@material-ui/core';
|
||||
import FlatButton from '../../UI/FlatButton';
|
||||
import Dialog from '../../UI/Dialog';
|
||||
import {
|
||||
EmailShareButton,
|
||||
FacebookShareButton,
|
||||
RedditShareButton,
|
||||
TwitterShareButton,
|
||||
WhatsappShareButton,
|
||||
EmailIcon,
|
||||
FacebookIcon,
|
||||
RedditIcon,
|
||||
TwitterIcon,
|
||||
WhatsappIcon,
|
||||
} from 'react-share';
|
||||
import { TextFieldWithButtonLayout } from '../../UI/Layout';
|
||||
import {
|
||||
getGame,
|
||||
getGameUrl,
|
||||
updateGame,
|
||||
type Game,
|
||||
} from '../../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
|
||||
const styles = {
|
||||
icon: {
|
||||
padding: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const ExplanationHeader = () => (
|
||||
<Column noMargin alignItems="center" justifyContent="center">
|
||||
<Line>
|
||||
<Text align="center">
|
||||
<Trans>
|
||||
Generate a unique link, playable from any computer or mobile phone's
|
||||
browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
);
|
||||
|
||||
type WebProjectLinkProps = {|
|
||||
build: ?Build,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string,
|
||||
|};
|
||||
|
||||
export const WebProjectLink = ({
|
||||
build,
|
||||
errored,
|
||||
exportStep,
|
||||
getGameThumbnailUrl,
|
||||
}: WebProjectLinkProps) => {
|
||||
const [showCopiedInfoBar, setShowCopiedInfoBar] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [game, setGame] = React.useState<?Game>(null);
|
||||
const [isGameLoading, setIsGameLoading] = React.useState<boolean>(false);
|
||||
const { getAuthorizationHeader, profile } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const exportPending = !errored && exportStep !== '' && exportStep !== 'done';
|
||||
const isBuildComplete = build && build.status === 'complete';
|
||||
const isBuildPublished = build && game && build.id === game.publicWebBuildId;
|
||||
const gameUrl = getGameUrl(game);
|
||||
const buildUrl =
|
||||
exportPending || !isBuildComplete
|
||||
? null
|
||||
: isBuildPublished
|
||||
? gameUrl
|
||||
: getBuildArtifactUrl(build, 's3Key');
|
||||
|
||||
const loadGame = React.useCallback(
|
||||
async () => {
|
||||
const gameId = build && build.gameId;
|
||||
if (!profile || !gameId) return;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const game = await getGame(getAuthorizationHeader, id, gameId);
|
||||
setGame(game);
|
||||
setIsGameLoading(false);
|
||||
} catch (err) {
|
||||
setIsGameLoading(false);
|
||||
console.error('Unable to load the game', err);
|
||||
}
|
||||
},
|
||||
[build, getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Load game only once
|
||||
if (!game && isBuildComplete) {
|
||||
loadGame();
|
||||
}
|
||||
},
|
||||
[game, loadGame, isBuildComplete]
|
||||
);
|
||||
|
||||
const onOpen = () => {
|
||||
if (!buildUrl) return;
|
||||
Window.openExternalURL(buildUrl);
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
if (!buildUrl) return;
|
||||
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
|
||||
navigator.clipboard.writeText(buildUrl);
|
||||
setShowCopiedInfoBar(true);
|
||||
};
|
||||
|
||||
const onShare = async () => {
|
||||
if (!buildUrl || !navigator.share) return;
|
||||
|
||||
// We are on mobile (or on browsers supporting sharing using the system dialog).
|
||||
const shareData = {
|
||||
title: 'My GDevelop game',
|
||||
text: 'Try the game I just created with #gdevelop',
|
||||
url: buildUrl,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
} catch (err) {
|
||||
console.error("Couldn't share the game", err);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (exportStep === 'done') {
|
||||
setIsShareDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[exportStep]
|
||||
);
|
||||
|
||||
const onUpdatePublicBuild = React.useCallback(
|
||||
async () => {
|
||||
if (!profile || !game || !build) return;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const updatedGame = await updateGame(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
game.id,
|
||||
{
|
||||
publicWebBuildId: build.id,
|
||||
thumbnailUrl: build.id ? getGameThumbnailUrl(build.id) : undefined,
|
||||
}
|
||||
);
|
||||
setGame(updatedGame);
|
||||
setIsGameLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Unable to update the game', err);
|
||||
setIsGameLoading(false);
|
||||
}
|
||||
},
|
||||
[game, getAuthorizationHeader, profile, build, getGameThumbnailUrl]
|
||||
);
|
||||
|
||||
if (!build && !exportStep) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{exportPending && (
|
||||
<>
|
||||
<Text>
|
||||
<Trans>Just a few seconds while we generate the link...</Trans>
|
||||
</Text>
|
||||
<LinearProgress />
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
title={<Trans>Share your game</Trans>}
|
||||
actions={[
|
||||
<FlatButton
|
||||
key="close"
|
||||
label={<Trans>Back</Trans>}
|
||||
primary={false}
|
||||
onClick={() => setIsShareDialogOpen(false)}
|
||||
/>,
|
||||
]}
|
||||
open={isShareDialogOpen}
|
||||
onRequestClose={() => setIsShareDialogOpen(false)}
|
||||
>
|
||||
{buildUrl && !isGameLoading ? (
|
||||
<Column noMargin>
|
||||
<TextFieldWithButtonLayout
|
||||
noFloatingLabelText
|
||||
renderTextField={() => (
|
||||
<TextField
|
||||
value={buildUrl}
|
||||
readOnly
|
||||
fullWidth
|
||||
endAdornment={
|
||||
<IconButton onClick={onCopy} tooltip={t`Copy`} edge="end">
|
||||
<Copy />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderButton={style => (
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Open</Trans>}
|
||||
onClick={onOpen}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isBuildPublished && navigator.share && (
|
||||
<Line justifyContent="flex-end">
|
||||
<FlatButton
|
||||
label={<Trans>Share</Trans>}
|
||||
onClick={onShare}
|
||||
icon={<Share />}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{isBuildPublished && !navigator.share && (
|
||||
<Line justifyContent="space-between">
|
||||
<Column justifyContent="center">
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
This link is unique to your game. Show what you made to
|
||||
the community!
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Column>
|
||||
<Column justifyContent="flex-end">
|
||||
<Line>
|
||||
<FacebookShareButton
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
quote={`Try the game I just created with GDevelop.io`}
|
||||
hashtag="#gdevelop"
|
||||
>
|
||||
<FacebookIcon size={32} round />
|
||||
</FacebookShareButton>
|
||||
<RedditShareButton
|
||||
url={buildUrl}
|
||||
title={`Try the game I just created with r/gdevelop`}
|
||||
style={styles.icon}
|
||||
>
|
||||
<RedditIcon size={32} round />
|
||||
</RedditShareButton>
|
||||
<TwitterShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
hashtags={['gdevelop']}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<TwitterIcon size={32} round />
|
||||
</TwitterShareButton>
|
||||
<WhatsappShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<WhatsappIcon size={32} round />
|
||||
</WhatsappShareButton>
|
||||
<EmailShareButton
|
||||
subject="My GDevelop game"
|
||||
body="Try the game I just created with GDevelop.io"
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<EmailIcon size={32} round />
|
||||
</EmailShareButton>
|
||||
</Line>
|
||||
</Column>
|
||||
</Line>
|
||||
)}
|
||||
{!isBuildPublished && game && (
|
||||
<Line>
|
||||
<AlertMessage
|
||||
kind="info"
|
||||
renderRightButton={() => (
|
||||
<RaisedButton
|
||||
label={<Trans>Update your game</Trans>}
|
||||
onClick={onUpdatePublicBuild}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Trans>
|
||||
This link is private so you can share it with friends and
|
||||
testers. When you're ready you can update your Liluo.io game
|
||||
page.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
)}
|
||||
</Column>
|
||||
) : (
|
||||
<Column alignItems="center">
|
||||
<CircularProgress />
|
||||
</Column>
|
||||
)}
|
||||
<InfoBar
|
||||
message={<Trans>Copied to clipboard!</Trans>}
|
||||
visible={showCopiedInfoBar}
|
||||
hide={() => setShowCopiedInfoBar(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const onlineWebExporter = {
|
||||
key: 'onlinewebexport',
|
||||
tabName: 'Web',
|
||||
name: <Trans>Web</Trans>,
|
||||
helpPage: '/publishing/web',
|
||||
};
|
@@ -0,0 +1,431 @@
|
||||
// @flow
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import TextField from '../../../UI/TextField';
|
||||
import {
|
||||
getBuildArtifactUrl,
|
||||
getWebBuildThumbnailUrl,
|
||||
type Build,
|
||||
} from '../../../Utils/GDevelopServices/Build';
|
||||
import { type BuildStep } from '../../Builds/BuildStepsProgress';
|
||||
import RaisedButton from '../../../UI/RaisedButton';
|
||||
import Window from '../../../Utils/Window';
|
||||
import Copy from '../../../UI/CustomSvgIcons/Copy';
|
||||
import Share from '@material-ui/icons/Share';
|
||||
import InfoBar from '../../../UI/Messages/InfoBar';
|
||||
import IconButton from '../../../UI/IconButton';
|
||||
import { CircularProgress, LinearProgress } from '@material-ui/core';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import Dialog from '../../../UI/Dialog';
|
||||
import {
|
||||
EmailShareButton,
|
||||
FacebookShareButton,
|
||||
RedditShareButton,
|
||||
TwitterShareButton,
|
||||
WhatsappShareButton,
|
||||
EmailIcon,
|
||||
FacebookIcon,
|
||||
RedditIcon,
|
||||
TwitterIcon,
|
||||
WhatsappIcon,
|
||||
} from 'react-share';
|
||||
import { TextFieldWithButtonLayout } from '../../../UI/Layout';
|
||||
import {
|
||||
getGame,
|
||||
getGameUrl,
|
||||
updateGame,
|
||||
setGameSlug,
|
||||
getGameSlugs,
|
||||
type Game,
|
||||
type GameSlug,
|
||||
} from '../../../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import AlertMessage from '../../../UI/AlertMessage';
|
||||
import OnlineGamePropertiesDialog from './OnlineGamePropertiesDialog';
|
||||
import { showErrorBox } from '../../../UI/Messages/MessageBox';
|
||||
import { type PartialGameChange } from '../../../GameDashboard/PublicGamePropertiesDialog';
|
||||
|
||||
const styles = {
|
||||
icon: {
|
||||
padding: 5,
|
||||
},
|
||||
};
|
||||
|
||||
type OnlineGameLinkProps = {|
|
||||
build: ?Build,
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
|};
|
||||
|
||||
const OnlineGameLink = ({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
}: OnlineGameLinkProps) => {
|
||||
const [showCopiedInfoBar, setShowCopiedInfoBar] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [
|
||||
isOnlineGamePropertiesDialogOpen,
|
||||
setIsOnlineGamePropertiesDialogOpen,
|
||||
] = React.useState<boolean>(false);
|
||||
const [game, setGame] = React.useState<?Game>(null);
|
||||
const [slug, setSlug] = React.useState<?GameSlug>(null);
|
||||
const [isGameLoading, setIsGameLoading] = React.useState<boolean>(false);
|
||||
const { getAuthorizationHeader, profile } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const exportPending = !errored && exportStep !== '' && exportStep !== 'done';
|
||||
const isBuildComplete = build && build.status === 'complete';
|
||||
const isBuildPublished = build && game && build.id === game.publicWebBuildId;
|
||||
const gameUrl = getGameUrl(game, slug);
|
||||
const buildUrl =
|
||||
exportPending || !isBuildComplete
|
||||
? null
|
||||
: isBuildPublished
|
||||
? gameUrl
|
||||
: getBuildArtifactUrl(build, 's3Key');
|
||||
|
||||
const loadGame = React.useCallback(
|
||||
async () => {
|
||||
const gameId = build && build.gameId;
|
||||
if (!profile || !gameId) return;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const [game, slugs] = await Promise.all([
|
||||
getGame(getAuthorizationHeader, id, gameId),
|
||||
getGameSlugs(getAuthorizationHeader, id, gameId).catch(err => {
|
||||
console.error('Unable to get the game slug', err);
|
||||
}),
|
||||
]);
|
||||
setGame(game);
|
||||
if (slugs && slugs.length > 0) {
|
||||
setSlug(slugs[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unable to load the game', err);
|
||||
} finally {
|
||||
setIsGameLoading(false);
|
||||
}
|
||||
},
|
||||
[build, getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Load game only once
|
||||
if (!game && isBuildComplete) {
|
||||
loadGame();
|
||||
}
|
||||
},
|
||||
[game, loadGame, isBuildComplete]
|
||||
);
|
||||
|
||||
const onOpen = () => {
|
||||
if (!buildUrl) return;
|
||||
Window.openExternalURL(buildUrl);
|
||||
};
|
||||
|
||||
const onCopy = () => {
|
||||
if (!buildUrl) return;
|
||||
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
|
||||
navigator.clipboard.writeText(buildUrl);
|
||||
setShowCopiedInfoBar(true);
|
||||
};
|
||||
|
||||
const onShare = async () => {
|
||||
if (!buildUrl || !navigator.share) return;
|
||||
|
||||
// We are on mobile (or on browsers supporting sharing using the system dialog).
|
||||
const shareData = {
|
||||
title: 'My GDevelop game',
|
||||
text: 'Try the game I just created with #gdevelop',
|
||||
url: buildUrl,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
} catch (err) {
|
||||
console.error("Couldn't share the game", err);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (exportStep === 'done') {
|
||||
setIsShareDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[exportStep]
|
||||
);
|
||||
|
||||
const onGameUpdate = React.useCallback(
|
||||
async (
|
||||
partialGameChange: PartialGameChange,
|
||||
i18n: I18nType
|
||||
): Promise<boolean> => {
|
||||
if (!profile || !game || !build) return false;
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
setIsGameLoading(true);
|
||||
const updatedGame = await updateGame(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
game.id,
|
||||
{
|
||||
gameName: project.getName(),
|
||||
description: project.getDescription(),
|
||||
categories: project.getCategories().toJSArray(),
|
||||
playWithGamepad: project.isPlayableWithGamepad(),
|
||||
playWithKeyboard: project.isPlayableWithKeyboard(),
|
||||
playWithMobile: project.isPlayableWithMobile(),
|
||||
orientation: project.getOrientation(),
|
||||
publicWebBuildId: build.id,
|
||||
thumbnailUrl: getWebBuildThumbnailUrl(project, build.id),
|
||||
discoverable: partialGameChange.discoverable,
|
||||
}
|
||||
);
|
||||
setGame(updatedGame);
|
||||
const { userSlug, gameSlug } = partialGameChange;
|
||||
if (userSlug && gameSlug && userSlug === profile.username) {
|
||||
try {
|
||||
await setGameSlug(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
game.id,
|
||||
userSlug,
|
||||
gameSlug
|
||||
);
|
||||
setSlug({ username: userSlug, gameSlug: gameSlug, createdAt: 0 });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Unable to update the game slug:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(
|
||||
t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.`
|
||||
) +
|
||||
' ' +
|
||||
i18n._(t`Verify your internet connection or try again later.`),
|
||||
rawError: error,
|
||||
errorId: 'game-slug-update-error',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorBox({
|
||||
message: i18n._(
|
||||
t`There was an error updating your game. Verify that your internet connection is working or try again later.`
|
||||
),
|
||||
rawError: err,
|
||||
errorId: 'update-game-error',
|
||||
});
|
||||
console.error('Unable to update the game', err);
|
||||
return false;
|
||||
} finally {
|
||||
setIsGameLoading(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[game, getAuthorizationHeader, profile, build, project]
|
||||
);
|
||||
|
||||
if (!build && !exportStep) return null;
|
||||
|
||||
const dialogActions = [
|
||||
<FlatButton
|
||||
key="close"
|
||||
label={<Trans>Close</Trans>}
|
||||
primary={false}
|
||||
onClick={() => setIsShareDialogOpen(false)}
|
||||
/>,
|
||||
// Ensure there is a game loaded, meaning the user owns the game.
|
||||
game && buildUrl && !isBuildPublished && (
|
||||
<RaisedButton
|
||||
key="publish"
|
||||
label={<Trans>Verify and Publish to Liluo.io</Trans>}
|
||||
primary
|
||||
onClick={() => setIsOnlineGamePropertiesDialogOpen(true)}
|
||||
/>
|
||||
),
|
||||
];
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<>
|
||||
{exportPending && (
|
||||
<>
|
||||
<Text>
|
||||
<Trans>Just a few seconds while we generate the link...</Trans>
|
||||
</Text>
|
||||
<LinearProgress />
|
||||
</>
|
||||
)}
|
||||
{isShareDialogOpen && (
|
||||
<Dialog
|
||||
title={<Trans>Share your game</Trans>}
|
||||
actions={dialogActions}
|
||||
open
|
||||
onRequestClose={() => setIsShareDialogOpen(false)}
|
||||
>
|
||||
{buildUrl && !isGameLoading ? (
|
||||
<Column noMargin>
|
||||
<TextFieldWithButtonLayout
|
||||
noFloatingLabelText
|
||||
renderTextField={() => (
|
||||
<TextField
|
||||
value={buildUrl}
|
||||
readOnly
|
||||
fullWidth
|
||||
endAdornment={
|
||||
<IconButton
|
||||
onClick={onCopy}
|
||||
tooltip={t`Copy`}
|
||||
edge="end"
|
||||
>
|
||||
<Copy />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderButton={style => (
|
||||
<RaisedButton
|
||||
primary
|
||||
label={<Trans>Open</Trans>}
|
||||
onClick={onOpen}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isBuildPublished && navigator.share && (
|
||||
<Line justifyContent="flex-end">
|
||||
<FlatButton
|
||||
label={<Trans>Share</Trans>}
|
||||
onClick={onShare}
|
||||
icon={<Share />}
|
||||
/>
|
||||
</Line>
|
||||
)}
|
||||
{isBuildPublished && !navigator.share && (
|
||||
<Line justifyContent="space-between">
|
||||
<Column justifyContent="center">
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
Your game is published! Share it with the community!
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Column>
|
||||
<Column justifyContent="flex-end">
|
||||
<Line>
|
||||
<FacebookShareButton
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
quote={`Try the game I just created with GDevelop.io`}
|
||||
hashtag="#gdevelop"
|
||||
>
|
||||
<FacebookIcon size={32} round />
|
||||
</FacebookShareButton>
|
||||
<RedditShareButton
|
||||
url={buildUrl}
|
||||
title={`Try the game I just created with r/gdevelop`}
|
||||
style={styles.icon}
|
||||
>
|
||||
<RedditIcon size={32} round />
|
||||
</RedditShareButton>
|
||||
<TwitterShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
hashtags={['gdevelop']}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<TwitterIcon size={32} round />
|
||||
</TwitterShareButton>
|
||||
<WhatsappShareButton
|
||||
title={`Try the game I just created with GDevelop.io`}
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<WhatsappIcon size={32} round />
|
||||
</WhatsappShareButton>
|
||||
<EmailShareButton
|
||||
subject="My GDevelop game"
|
||||
body="Try the game I just created with GDevelop.io"
|
||||
url={buildUrl}
|
||||
style={styles.icon}
|
||||
>
|
||||
<EmailIcon size={32} round />
|
||||
</EmailShareButton>
|
||||
</Line>
|
||||
</Column>
|
||||
</Line>
|
||||
)}
|
||||
{!isBuildPublished && game && (
|
||||
<Line>
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
This link is private so you can share it with friends
|
||||
and testers. When you're ready you can update your
|
||||
Liluo.io game page.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
)}
|
||||
</Column>
|
||||
) : (
|
||||
<Column alignItems="center">
|
||||
<CircularProgress />
|
||||
</Column>
|
||||
)}
|
||||
<InfoBar
|
||||
message={<Trans>Copied to clipboard!</Trans>}
|
||||
visible={showCopiedInfoBar}
|
||||
hide={() => setShowCopiedInfoBar(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{game && build && isOnlineGamePropertiesDialogOpen && (
|
||||
<OnlineGamePropertiesDialog
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
buildId={build.id}
|
||||
onClose={() => setIsOnlineGamePropertiesDialogOpen(false)}
|
||||
onApply={async partialGameChange => {
|
||||
const isGameUpdated = await onGameUpdate(
|
||||
partialGameChange,
|
||||
i18n
|
||||
);
|
||||
if (isGameUpdated) {
|
||||
setIsOnlineGamePropertiesDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
game={game}
|
||||
slug={slug}
|
||||
isLoading={isGameLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineGameLink;
|
@@ -0,0 +1,161 @@
|
||||
// @flow
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type Game, type GameSlug } from '../../../Utils/GDevelopServices/Game';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import Dialog from '../../../UI/Dialog';
|
||||
import {
|
||||
cleanUpGameSlug,
|
||||
PublicGameProperties,
|
||||
} from '../../../GameDashboard/PublicGameProperties';
|
||||
import {
|
||||
applyPublicPropertiesToProject,
|
||||
type PartialGameChange,
|
||||
} from '../../../GameDashboard/PublicGamePropertiesDialog';
|
||||
import { getWebBuildThumbnailUrl } from '../../../Utils/GDevelopServices/Build';
|
||||
import RaisedButtonWithSplitMenu from '../../../UI/RaisedButtonWithSplitMenu';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
onSaveProject: () => Promise<void>,
|
||||
buildId: string,
|
||||
game: Game,
|
||||
slug: ?GameSlug,
|
||||
onClose: () => void,
|
||||
onApply: PartialGameChange => Promise<void>,
|
||||
isLoading: boolean,
|
||||
|};
|
||||
|
||||
export const OnlineGamePropertiesDialog = ({
|
||||
project,
|
||||
onSaveProject,
|
||||
buildId,
|
||||
game,
|
||||
slug,
|
||||
onClose,
|
||||
onApply,
|
||||
isLoading,
|
||||
}: Props) => {
|
||||
const { profile } = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const [name, setName] = React.useState<string>(project.getName());
|
||||
const [categories, setCategories] = React.useState<string[]>(
|
||||
project.getCategories().toJSArray()
|
||||
);
|
||||
const [description, setDescription] = React.useState<string>(
|
||||
project.getDescription()
|
||||
);
|
||||
const [authorIds, setAuthorIds] = React.useState<string[]>(
|
||||
project.getAuthorIds().toJSArray()
|
||||
);
|
||||
const [playWithKeyboard, setPlayableWithKeyboard] = React.useState<boolean>(
|
||||
project.isPlayableWithKeyboard()
|
||||
);
|
||||
const [playWithGamepad, setPlayableWithGamepad] = React.useState<boolean>(
|
||||
project.isPlayableWithGamepad()
|
||||
);
|
||||
const [playWithMobile, setPlayableWithMobile] = React.useState<boolean>(
|
||||
project.isPlayableWithMobile()
|
||||
);
|
||||
const [userSlug, setUserSlug] = React.useState<string>(
|
||||
(slug && slug.username) || (profile && profile.username) || ''
|
||||
);
|
||||
const [gameSlug, setGameSlug] = React.useState<string>(
|
||||
(slug && slug.gameSlug) || cleanUpGameSlug(project.getName())
|
||||
);
|
||||
const [orientation, setOrientation] = React.useState<string>(
|
||||
project.getOrientation()
|
||||
);
|
||||
const [discoverable, setDiscoverable] = React.useState<boolean>(
|
||||
!!game.discoverable
|
||||
);
|
||||
const thumbnailUrl = getWebBuildThumbnailUrl(project, buildId);
|
||||
|
||||
const saveProjectAndPublish = async () => {
|
||||
await onSaveProject();
|
||||
await onPublish();
|
||||
};
|
||||
|
||||
const onPublish = async () => {
|
||||
// Update the project with the new properties before updating the game.
|
||||
if (
|
||||
applyPublicPropertiesToProject(project, {
|
||||
name,
|
||||
categories: categories || [],
|
||||
description: description || '',
|
||||
authorIds,
|
||||
playWithKeyboard: !!playWithKeyboard,
|
||||
playWithGamepad: !!playWithGamepad,
|
||||
playWithMobile: !!playWithMobile,
|
||||
orientation: orientation || 'default',
|
||||
})
|
||||
) {
|
||||
await onApply({ discoverable, userSlug, gameSlug });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={<Trans>Verify your game info before publishing</Trans>}
|
||||
onRequestClose={onClose}
|
||||
actions={[
|
||||
<FlatButton
|
||||
label={<Trans>Back</Trans>}
|
||||
key="back"
|
||||
primary={false}
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
<RaisedButtonWithSplitMenu
|
||||
label={<Trans>Save project and publish</Trans>}
|
||||
key="publish"
|
||||
primary
|
||||
onClick={() => {
|
||||
saveProjectAndPublish();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
buildMenuTemplate={i18n => [
|
||||
{
|
||||
label: i18n._(t`Publish without saving project`),
|
||||
click: onPublish,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
]}
|
||||
cannotBeDismissed={isLoading}
|
||||
open
|
||||
>
|
||||
<PublicGameProperties
|
||||
name={name}
|
||||
setName={setName}
|
||||
categories={categories}
|
||||
setCategories={setCategories}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
project={project}
|
||||
authorIds={authorIds}
|
||||
setAuthorIds={setAuthorIds}
|
||||
setPlayableWithKeyboard={setPlayableWithKeyboard}
|
||||
playWithKeyboard={playWithKeyboard}
|
||||
setPlayableWithGamepad={setPlayableWithGamepad}
|
||||
playWithGamepad={playWithGamepad}
|
||||
setPlayableWithMobile={setPlayableWithMobile}
|
||||
playWithMobile={playWithMobile}
|
||||
setOrientation={setOrientation}
|
||||
orientation={orientation}
|
||||
userSlug={userSlug}
|
||||
setUserSlug={setUserSlug}
|
||||
gameSlug={gameSlug}
|
||||
setGameSlug={setGameSlug}
|
||||
discoverable={discoverable}
|
||||
setDiscoverable={setDiscoverable}
|
||||
displayThumbnail
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnlineGamePropertiesDialog;
|
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import OnlineGameLink from './OnlineGameLink';
|
||||
|
||||
const ExplanationHeader = () => (
|
||||
<Column noMargin alignItems="center" justifyContent="center">
|
||||
<Line>
|
||||
<Text align="center">
|
||||
<Trans>
|
||||
Generate a unique link, playable from any computer or mobile phone's
|
||||
browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Line>
|
||||
</Column>
|
||||
);
|
||||
|
||||
const onlineWebExporter = {
|
||||
key: 'onlinewebexport',
|
||||
tabName: 'Web',
|
||||
name: <Trans>Web</Trans>,
|
||||
helpPage: '/publishing/web',
|
||||
};
|
||||
|
||||
export { onlineWebExporter, ExplanationHeader, OnlineGameLink };
|
@@ -17,10 +17,9 @@ import {
|
||||
type ExportPipeline,
|
||||
type ExportPipelineContext,
|
||||
} from '../ExportPipeline.flow';
|
||||
import { type BuildStep } from '../Builds/BuildStepsProgress';
|
||||
import {
|
||||
ExplanationHeader,
|
||||
WebProjectLink,
|
||||
OnlineGameLink,
|
||||
} from '../GenericExporters/OnlineWebExport';
|
||||
const path = optionalRequire('path');
|
||||
const os = optionalRequire('os');
|
||||
@@ -67,17 +66,19 @@ export const localOnlineWebExportPipeline: ExportPipeline<
|
||||
|
||||
renderLaunchButtonLabel: () => <Trans>Generate link</Trans>,
|
||||
|
||||
renderCustomStepsProgress: (
|
||||
build: ?Build,
|
||||
errored: boolean,
|
||||
exportStep: BuildStep,
|
||||
getGameThumbnailUrl: (buildId: string) => ?string
|
||||
) => (
|
||||
<WebProjectLink
|
||||
renderCustomStepsProgress: ({
|
||||
build,
|
||||
project,
|
||||
onSaveProject,
|
||||
errored,
|
||||
exportStep,
|
||||
}) => (
|
||||
<OnlineGameLink
|
||||
build={build}
|
||||
project={project}
|
||||
onSaveProject={onSaveProject}
|
||||
errored={errored}
|
||||
exportStep={exportStep}
|
||||
getGameThumbnailUrl={getGameThumbnailUrl}
|
||||
/>
|
||||
),
|
||||
|
||||
|
68
newIDE/app/src/GameDashboard/CyrillicToLatin.json
Normal file
68
newIDE/app/src/GameDashboard/CyrillicToLatin.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"А": "A",
|
||||
"Б": "B",
|
||||
"В": "V",
|
||||
"Г": "G",
|
||||
"Д": "D",
|
||||
"Е": "E",
|
||||
"Ё": "E",
|
||||
"Ж": "Zh",
|
||||
"З": "Z",
|
||||
"И": "I",
|
||||
"Й": "J",
|
||||
"К": "K",
|
||||
"Л": "L",
|
||||
"М": "M",
|
||||
"Н": "N",
|
||||
"О": "O",
|
||||
"П": "P",
|
||||
"Р": "R",
|
||||
"С": "S",
|
||||
"Т": "T",
|
||||
"У": "U",
|
||||
"Ф": "F",
|
||||
"Х": "H",
|
||||
"Ц": "Ts",
|
||||
"Ч": "Ch",
|
||||
"Ш": "Sh",
|
||||
"Щ": "Shch",
|
||||
"Ы": "Y",
|
||||
"Э": "E",
|
||||
"Ю": "Yu",
|
||||
"Я": "Ya",
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"й": "j",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "ts",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "shch",
|
||||
"ы": "y",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
"Ъ": "",
|
||||
"ъ": "",
|
||||
"Ь": "",
|
||||
"ь": ""
|
||||
}
|
@@ -1,45 +1,22 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Card, CardActions, CardHeader, Chip, Paper } from '@material-ui/core';
|
||||
import { Card, CardActions, CardHeader, Chip } from '@material-ui/core';
|
||||
import * as React from 'react';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import { getGameUrl, type Game } from '../Utils/GDevelopServices/Game';
|
||||
import TimelineIcon from '@material-ui/icons/Timeline';
|
||||
import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay';
|
||||
import TuneIcon from '@material-ui/icons/Tune';
|
||||
import { ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import Window from '../Utils/Window';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
|
||||
const styles = {
|
||||
image: {
|
||||
display: 'block',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
thumbnail: {
|
||||
width: 240,
|
||||
height: 135,
|
||||
},
|
||||
};
|
||||
import { GameThumbnail } from './GameThumbnail';
|
||||
|
||||
type Props = {|
|
||||
game: Game,
|
||||
isCurrentGame: boolean,
|
||||
onOpenDetails: () => void,
|
||||
onOpenBuilds: () => void,
|
||||
onOpenAnalytics: () => void,
|
||||
onOpenGameManager: () => void,
|
||||
|};
|
||||
|
||||
export const GameCard = ({
|
||||
game,
|
||||
isCurrentGame,
|
||||
onOpenDetails,
|
||||
onOpenBuilds,
|
||||
onOpenAnalytics,
|
||||
}: Props) => {
|
||||
export const GameCard = ({ game, isCurrentGame, onOpenGameManager }: Props) => {
|
||||
const openGameUrl = () => {
|
||||
const url = getGameUrl(game);
|
||||
if (!url) return;
|
||||
@@ -49,31 +26,11 @@ export const GameCard = ({
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Card key={game.id}>
|
||||
<Line>
|
||||
{game.thumbnailUrl ? (
|
||||
<img
|
||||
src={game.thumbnailUrl}
|
||||
style={{
|
||||
...styles.image,
|
||||
...styles.thumbnail,
|
||||
}}
|
||||
alt={game.gameName}
|
||||
title={game.gameName}
|
||||
/>
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
style={{
|
||||
...styles.thumbnail,
|
||||
whiteSpace: 'normal',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<EmptyMessage>
|
||||
<Trans>No thumbnail set</Trans>
|
||||
</EmptyMessage>
|
||||
</Paper>
|
||||
)}
|
||||
<ResponsiveLineStackLayout>
|
||||
<GameThumbnail
|
||||
gameName={game.gameName}
|
||||
thumbnailUrl={game.thumbnailUrl}
|
||||
/>
|
||||
<Column expand>
|
||||
<CardHeader
|
||||
title={game.gameName}
|
||||
@@ -95,7 +52,13 @@ export const GameCard = ({
|
||||
<Spacer />
|
||||
<Chip
|
||||
size="small"
|
||||
label={<Trans>Published on Liluo</Trans>}
|
||||
label={
|
||||
game.discoverable ? (
|
||||
<Trans>Discoverable on Liluo.io</Trans>
|
||||
) : (
|
||||
<Trans>Published on Liluo.io</Trans>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -110,30 +73,19 @@ export const GameCard = ({
|
||||
>
|
||||
{game.publicWebBuildId && (
|
||||
<RaisedButton
|
||||
label={<Trans>Open</Trans>}
|
||||
label={<Trans>Open in browser</Trans>}
|
||||
onClick={openGameUrl}
|
||||
primary
|
||||
/>
|
||||
)}
|
||||
<FlatButton
|
||||
icon={<TuneIcon />}
|
||||
label={<Trans>Details</Trans>}
|
||||
onClick={onOpenDetails}
|
||||
/>
|
||||
<FlatButton
|
||||
icon={<PlaylistPlayIcon />}
|
||||
label={<Trans>Builds</Trans>}
|
||||
onClick={onOpenBuilds}
|
||||
/>
|
||||
<FlatButton
|
||||
icon={<TimelineIcon />}
|
||||
label={<Trans>Analytics</Trans>}
|
||||
onClick={onOpenAnalytics}
|
||||
<RaisedButton
|
||||
label={<Trans>Manage game</Trans>}
|
||||
onClick={onOpenGameManager}
|
||||
primary
|
||||
/>
|
||||
</ResponsiveLineStackLayout>
|
||||
</CardActions>
|
||||
</Column>
|
||||
</Line>
|
||||
</ResponsiveLineStackLayout>
|
||||
</Card>
|
||||
)}
|
||||
</I18n>
|
||||
|
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
deleteGame,
|
||||
getPublicGame,
|
||||
setGameUserAcls,
|
||||
setGameSlug,
|
||||
getAclsFromUserIds,
|
||||
getCategoryName,
|
||||
} from '../Utils/GDevelopServices/Game';
|
||||
@@ -41,13 +41,14 @@ import PlaceholderLoader from '../UI/PlaceholderLoader';
|
||||
import {
|
||||
PublicGamePropertiesDialog,
|
||||
type PartialGameChange,
|
||||
} from '../ProjectManager/PublicGamePropertiesDialog';
|
||||
} from './PublicGamePropertiesDialog';
|
||||
import TextField from '../UI/TextField';
|
||||
import KeyboardIcon from '@material-ui/icons/Keyboard';
|
||||
import SportsEsportsIcon from '@material-ui/icons/SportsEsports';
|
||||
import SmartphoneIcon from '@material-ui/icons/Smartphone';
|
||||
import Crown from '../UI/CustomSvgIcons/Crown';
|
||||
import { showErrorBox } from '../UI/Messages/MessageBox';
|
||||
import { showErrorBox, showWarningBox } from '../UI/Messages/MessageBox';
|
||||
import LeaderboardAdmin from './LeaderboardAdmin';
|
||||
|
||||
const styles = {
|
||||
tableRowStatColumn: {
|
||||
@@ -55,7 +56,11 @@ const styles = {
|
||||
},
|
||||
};
|
||||
|
||||
export type GamesDetailsTab = 'details' | 'builds' | 'analytics';
|
||||
export type GamesDetailsTab =
|
||||
| 'details'
|
||||
| 'builds'
|
||||
| 'analytics'
|
||||
| 'leaderboards';
|
||||
|
||||
type Props = {|
|
||||
game: Game,
|
||||
@@ -81,10 +86,16 @@ export const GameDetailsDialog = ({
|
||||
const [gameRollingMetrics, setGameMetrics] = React.useState<?GameMetrics>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [
|
||||
gameUnregisterErrorText,
|
||||
setGameUnregisterErrorText,
|
||||
] = React.useState<?string>(null);
|
||||
const [gameRollingMetricsError, setGameMetricsError] = React.useState<?Error>(
|
||||
null
|
||||
);
|
||||
const [isGameMetricsLoading, setIsGameMetricsLoading] = React.useState(false);
|
||||
const [isGameUpdating, setIsGameUpdating] = React.useState(false);
|
||||
|
||||
const yesterdayIsoDate = formatISO(subDays(new Date(), 1), {
|
||||
representation: 'date',
|
||||
@@ -152,51 +163,113 @@ export const GameDetailsDialog = ({
|
||||
[loadPublicGame]
|
||||
);
|
||||
|
||||
const handleGameUpdated = React.useCallback(
|
||||
(updatedGame: Game) => {
|
||||
// Set Public Game to null to show the loader.
|
||||
// It will be refetched thanks to loadPublicGame, because Game is updated.
|
||||
setPublicGame(null);
|
||||
onGameUpdated(updatedGame);
|
||||
},
|
||||
[onGameUpdated]
|
||||
);
|
||||
|
||||
const updateGameFromProject = async (
|
||||
partialGameChange: PartialGameChange,
|
||||
i18n: I18nType
|
||||
) => {
|
||||
if (!project || !profile) return;
|
||||
): Promise<boolean> => {
|
||||
if (!project || !profile) return false;
|
||||
const { id } = profile;
|
||||
|
||||
// Set public game to null as it will be refetched automatically by the callback above.
|
||||
setPublicGame(null);
|
||||
const ownerIds = partialGameChange.ownerIds;
|
||||
if (!ownerIds || !ownerIds.length) {
|
||||
showWarningBox(
|
||||
i18n._(
|
||||
t`You must select at least one user to be the owner of the game.`
|
||||
),
|
||||
{ delayToNextTick: true }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsGameUpdating(true);
|
||||
const gameId = project.getProjectUuid();
|
||||
const updatedGame = await updateGame(getAuthorizationHeader, id, gameId, {
|
||||
authorName: project.getAuthor() || 'Unspecified publisher',
|
||||
gameName: project.getName() || 'Untitle game',
|
||||
gameName: project.getName() || 'Untitled game',
|
||||
categories: project.getCategories().toJSArray() || [],
|
||||
description: project.getDescription() || '',
|
||||
playWithKeyboard: project.isPlayableWithKeyboard(),
|
||||
playWithGamepad: project.isPlayableWithGamepad(),
|
||||
playWithMobile: project.isPlayableWithMobile(),
|
||||
orientation: project.getOrientation(),
|
||||
// The thumbnailUrl is updated only when a build is made public.
|
||||
discoverable: partialGameChange.discoverable,
|
||||
});
|
||||
if (
|
||||
partialGameChange.userSlug &&
|
||||
partialGameChange.gameSlug &&
|
||||
partialGameChange.userSlug === profile.username
|
||||
) {
|
||||
try {
|
||||
await setGameSlug(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
gameId,
|
||||
partialGameChange.userSlug,
|
||||
partialGameChange.gameSlug
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Unable to update the game slug:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(
|
||||
t`Unable to update the game slug. A slug must be 6 to 30 characters long and only contains letters, digits or dashes.`
|
||||
) +
|
||||
' ' +
|
||||
i18n._(t`Verify your internet connection or try again later.`),
|
||||
rawError: error,
|
||||
errorId: 'game-slug-update-error',
|
||||
});
|
||||
setIsGameUpdating(false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const authorAcls = getAclsFromUserIds(
|
||||
project.getAuthorIds().toJSArray()
|
||||
);
|
||||
const ownerAcls = getAclsFromUserIds(partialGameChange.ownerIds);
|
||||
const ownerAcls = getAclsFromUserIds(ownerIds);
|
||||
await setGameUserAcls(getAuthorizationHeader, id, gameId, {
|
||||
ownership: ownerAcls,
|
||||
author: authorAcls,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to update the game owners or authors:', error);
|
||||
console.error(
|
||||
'Unable to update the game owners or authors:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(t`Unable to update the game owners or authors.`) +
|
||||
i18n._(
|
||||
t`Unable to update the game owners or authors. Have you removed yourself from the owners?`
|
||||
) +
|
||||
' ' +
|
||||
i18n._(t`Verify your internet connection or try again later.`),
|
||||
rawError: error,
|
||||
errorId: 'game-acls-update-error',
|
||||
});
|
||||
setIsGameUpdating(false);
|
||||
return false;
|
||||
}
|
||||
onGameUpdated(updatedGame);
|
||||
handleGameUpdated(updatedGame);
|
||||
} catch (error) {
|
||||
console.error('Unable to update the game:', error);
|
||||
console.error(
|
||||
'Unable to update the game:',
|
||||
error.response || error.message
|
||||
);
|
||||
showErrorBox({
|
||||
message:
|
||||
i18n._(t`Unable to update the game details.`) +
|
||||
@@ -205,18 +278,39 @@ export const GameDetailsDialog = ({
|
||||
rawError: error,
|
||||
errorId: 'game-details-update-error',
|
||||
});
|
||||
setIsGameUpdating(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsGameUpdating(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
const unregisterGame = async () => {
|
||||
const unregisterGame = async (i18n: I18nType) => {
|
||||
if (!profile) return;
|
||||
const { id } = profile;
|
||||
|
||||
setGameUnregisterErrorText(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setIsGameUpdating(true);
|
||||
await deleteGame(getAuthorizationHeader, id, game.id);
|
||||
onGameDeleted();
|
||||
} catch (error) {
|
||||
console.error('Unable to delete the game:', error);
|
||||
if (
|
||||
error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.code === 'game-deletion/leaderboards-exist'
|
||||
) {
|
||||
setGameUnregisterErrorText(
|
||||
i18n._(
|
||||
t`You cannot unregister a game that has active leaderboards. To delete them, go in the Leaderboards tab, and delete them one by one.`
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsGameUpdating(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,8 +320,7 @@ export const GameDetailsDialog = ({
|
||||
|
||||
const { id } = profile;
|
||||
try {
|
||||
// Set public game to null as it will be refetched automatically by the callback above.
|
||||
setPublicGame(null);
|
||||
setIsGameUpdating(true);
|
||||
const updatedGame = await updateGame(
|
||||
getAuthorizationHeader,
|
||||
id,
|
||||
@@ -236,12 +329,14 @@ export const GameDetailsDialog = ({
|
||||
publicWebBuildId: null,
|
||||
}
|
||||
);
|
||||
onGameUpdated(updatedGame);
|
||||
handleGameUpdated(updatedGame);
|
||||
} catch (err) {
|
||||
console.error('Unable to update the game', err);
|
||||
} finally {
|
||||
setIsGameUpdating(false);
|
||||
}
|
||||
},
|
||||
[game, getAuthorizationHeader, profile, onGameUpdated]
|
||||
[game, getAuthorizationHeader, profile, handleGameUpdated]
|
||||
);
|
||||
|
||||
const authorUsernames =
|
||||
@@ -268,25 +363,41 @@ export const GameDetailsDialog = ({
|
||||
}
|
||||
open
|
||||
noMargin
|
||||
onRequestClose={onClose}
|
||||
flexColumnBody
|
||||
fullHeight={currentTab === 'leaderboards'}
|
||||
onRequestClose={() => {
|
||||
if (!isLoading) onClose();
|
||||
}}
|
||||
maxWidth="md"
|
||||
actions={[
|
||||
<FlatButton
|
||||
label={<Trans>Close</Trans>}
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
key="close"
|
||||
/>,
|
||||
]}
|
||||
secondaryActions={[
|
||||
<HelpButton key="help" helpPagePath="/interface/games-dashboard" />,
|
||||
<HelpButton
|
||||
key="help"
|
||||
helpPagePath={
|
||||
currentTab === 'leaderboards'
|
||||
? '/interface/games-dashboard/leaderboard-administration'
|
||||
: '/interface/games-dashboard'
|
||||
}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Tabs value={currentTab} onChange={setCurrentTab}>
|
||||
<Tab label={<Trans>Details</Trans>} value="details" />
|
||||
<Tab label={<Trans>Builds</Trans>} value="builds" />
|
||||
<Tab label={<Trans>Analytics</Trans>} value="analytics" />
|
||||
<Tab label={<Trans>Leaderboards</Trans>} value="leaderboards" />
|
||||
</Tabs>
|
||||
<Line>
|
||||
<Line expand>
|
||||
{currentTab === 'leaderboards' ? (
|
||||
<LeaderboardAdmin gameId={game.id} onLoading={setIsLoading} />
|
||||
) : null}
|
||||
{currentTab === 'details' ? (
|
||||
publicGameError ? (
|
||||
<PlaceholderError onRetry={loadPublicGame}>
|
||||
@@ -437,9 +548,10 @@ export const GameDetailsDialog = ({
|
||||
|
||||
if (!answer) return;
|
||||
|
||||
unregisterGame();
|
||||
unregisterGame(i18n);
|
||||
}}
|
||||
label={<Trans>Unregister this game</Trans>}
|
||||
disabled={isGameUpdating}
|
||||
/>
|
||||
<Spacer />
|
||||
{publicGame.publicWebBuildId && (
|
||||
@@ -447,14 +559,15 @@ export const GameDetailsDialog = ({
|
||||
<RaisedButton
|
||||
onClick={() => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
'Are you sure you want to unpublish this game? \n\nThis will make your Liluo unique game URL not accessible anymore. \n\nYou can decide anytime to publish it again.'
|
||||
'Are you sure you want to unpublish this game? \n\nThis will make your Liluo.io unique game URL not accessible anymore. \n\nYou can decide at any time to publish it again.'
|
||||
);
|
||||
|
||||
if (!answer) return;
|
||||
|
||||
unpublishGame();
|
||||
}}
|
||||
label={<Trans>Unpublish from Liluo</Trans>}
|
||||
label={<Trans>Unpublish from Liluo.io</Trans>}
|
||||
disabled={isGameUpdating}
|
||||
/>
|
||||
<Spacer />
|
||||
</>
|
||||
@@ -463,9 +576,14 @@ export const GameDetailsDialog = ({
|
||||
primary
|
||||
onClick={() => setIsPublicGamePropertiesDialogOpen(true)}
|
||||
label={<Trans>Edit game details</Trans>}
|
||||
disabled={!isGameOpenedAsProject}
|
||||
disabled={!isGameOpenedAsProject || isGameUpdating}
|
||||
/>
|
||||
</Line>
|
||||
{gameUnregisterErrorText ? (
|
||||
<PlaceholderError kind="error">
|
||||
{gameUnregisterErrorText}
|
||||
</PlaceholderError>
|
||||
) : null}
|
||||
</ColumnStackLayout>
|
||||
)
|
||||
) : null}
|
||||
@@ -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>
|
||||
|
48
newIDE/app/src/GameDashboard/GameThumbnail.js
Normal file
48
newIDE/app/src/GameDashboard/GameThumbnail.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// @flow
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Paper } from '@material-ui/core';
|
||||
import * as React from 'react';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
|
||||
const styles = {
|
||||
image: {
|
||||
display: 'block',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
thumbnail: {
|
||||
// 16/9 format
|
||||
width: 272,
|
||||
height: 153,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
thumbnailUrl?: string,
|
||||
gameName: string,
|
||||
|};
|
||||
|
||||
export const GameThumbnail = ({ thumbnailUrl, gameName }: Props) =>
|
||||
thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
style={{
|
||||
...styles.image,
|
||||
...styles.thumbnail,
|
||||
}}
|
||||
alt={gameName}
|
||||
title={gameName}
|
||||
/>
|
||||
) : (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
style={{
|
||||
...styles.thumbnail,
|
||||
whiteSpace: 'normal',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<EmptyMessage>
|
||||
<Trans>No thumbnail set</Trans>
|
||||
</EmptyMessage>
|
||||
</Paper>
|
||||
);
|
@@ -95,18 +95,10 @@ export const GamesList = (props: Props) => {
|
||||
key={game.id}
|
||||
isCurrentGame={!!projectUuid && game.id === projectUuid}
|
||||
game={game}
|
||||
onOpenDetails={() => {
|
||||
onOpenGameManager={() => {
|
||||
setOpenedGameInitialTab('details');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
onOpenBuilds={() => {
|
||||
setOpenedGameInitialTab('builds');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
onOpenAnalytics={() => {
|
||||
setOpenedGameInitialTab('analytics');
|
||||
setOpenedGame(game);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{openedGame && (
|
||||
|
@@ -0,0 +1,161 @@
|
||||
// @flow
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import * as React from 'react';
|
||||
import Table from '@material-ui/core/Table';
|
||||
import TableBody from '@material-ui/core/TableBody';
|
||||
import TableCell from '@material-ui/core/TableCell';
|
||||
import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import DeleteOutline from '@material-ui/icons/DeleteOutline';
|
||||
import Error from '@material-ui/icons/Error';
|
||||
import FirstPage from '@material-ui/icons/FirstPage';
|
||||
import NavigateBefore from '@material-ui/icons/NavigateBefore';
|
||||
import NavigateNext from '@material-ui/icons/NavigateNext';
|
||||
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import { Column, Line } from '../../UI/Grid';
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
import Text from '../../UI/Text';
|
||||
import { textEllipsisStyle } from '../../UI/TextEllipsis';
|
||||
import { type LeaderboardDisplayData } from '../../Utils/GDevelopServices/Play';
|
||||
|
||||
type Props = {|
|
||||
entries: ?Array<LeaderboardDisplayData>,
|
||||
onDeleteEntry: (entryId: string) => Promise<void>,
|
||||
isLoading: boolean,
|
||||
erroredEntry?: {| entryId: string, message: React.Node |},
|
||||
navigation: {|
|
||||
goToFirstPage: ?() => Promise<void>,
|
||||
goToPreviousPage: ?() => Promise<void>,
|
||||
goToNextPage: ?() => Promise<void>,
|
||||
|},
|
||||
|};
|
||||
|
||||
const LeaderboardEntriesTable = ({
|
||||
entries,
|
||||
onDeleteEntry,
|
||||
isLoading,
|
||||
erroredEntry,
|
||||
navigation,
|
||||
}: Props) => {
|
||||
if (!entries) return <PlaceholderLoader />;
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Column expand justifyContent="space-between">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{ width: '20%' }} align="center">
|
||||
<Trans>Score</Trans>
|
||||
</TableCell>
|
||||
<TableCell style={{ width: '50%' }}>
|
||||
<Trans>Player</Trans>
|
||||
</TableCell>
|
||||
<TableCell style={{ width: '15%' }}>
|
||||
<Trans>Date</Trans>
|
||||
</TableCell>
|
||||
<TableCell style={{ width: '15%' }}>
|
||||
<Trans>Action</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{entries.map(entry => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell align="center">{entry.score}</TableCell>
|
||||
<TableCell
|
||||
style={{
|
||||
...textEllipsisStyle,
|
||||
maxWidth: 0, // to trigger the text ellipsis when overflowing
|
||||
}}
|
||||
>
|
||||
<Tooltip title={entry.playerName}>
|
||||
<span>{entry.playerName}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={i18n.date(entry.createdAt, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
>
|
||||
<span>{i18n.date(entry.createdAt)}</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Line>
|
||||
<Tooltip title={'Remove entry'}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry(entry.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<DeleteOutline size={20} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{erroredEntry && erroredEntry.entryId === entry.id ? (
|
||||
<Tooltip title={erroredEntry.message}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {}} // wrap in icon button to match above icon padding
|
||||
>
|
||||
<Error size={20} color="error" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Line>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{entries.length === 0 ? (
|
||||
<Column expand justifyContent="center" alignItems="center">
|
||||
<Text size="body2">
|
||||
<Trans>No entries</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
) : (
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<IconButton
|
||||
tooltip={t`Go to first page`}
|
||||
disabled={!navigation.goToFirstPage}
|
||||
onClick={() => {
|
||||
if (navigation.goToFirstPage) navigation.goToFirstPage();
|
||||
}}
|
||||
>
|
||||
<FirstPage />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={t`Previous page`}
|
||||
disabled={!navigation.goToPreviousPage}
|
||||
onClick={() => {
|
||||
if (navigation.goToPreviousPage)
|
||||
navigation.goToPreviousPage();
|
||||
}}
|
||||
>
|
||||
<NavigateBefore />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={t`Next page`}
|
||||
disabled={!navigation.goToNextPage}
|
||||
onClick={() => {
|
||||
if (navigation.goToNextPage) navigation.goToNextPage();
|
||||
}}
|
||||
>
|
||||
<NavigateNext />
|
||||
</IconButton>
|
||||
</Line>
|
||||
)}
|
||||
</Column>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardEntriesTable;
|
895
newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js
Normal file
895
newIDE/app/src/GameDashboard/LeaderboardAdmin/index.js
Normal file
@@ -0,0 +1,895 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import List from '@material-ui/core/List';
|
||||
import ListItem from '@material-ui/core/ListItem';
|
||||
import ListItemAvatar from '@material-ui/core/ListItemAvatar';
|
||||
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import Switch from '@material-ui/core/Switch';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
|
||||
import Add from '@material-ui/icons/Add';
|
||||
import Save from '@material-ui/icons/Save';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import Label from '@material-ui/icons/Label';
|
||||
import Fingerprint from '@material-ui/icons/Fingerprint';
|
||||
import Update from '@material-ui/icons/Update';
|
||||
import Today from '@material-ui/icons/Today';
|
||||
import Sort from '@material-ui/icons/Sort';
|
||||
import PeopleAlt from '@material-ui/icons/PeopleAlt';
|
||||
import SwapVertical from '@material-ui/icons/SwapVert';
|
||||
import Refresh from '@material-ui/icons/Refresh';
|
||||
import Delete from '@material-ui/icons/Delete';
|
||||
import Visibility from '@material-ui/icons/Visibility';
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff';
|
||||
import Loop from '@material-ui/icons/Loop';
|
||||
|
||||
import Copy from '../../UI/CustomSvgIcons/Copy';
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
import { EmptyPlaceholder } from '../../UI/EmptyPlaceholder';
|
||||
import { Column, Line, Spacer } from '../../UI/Grid';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import PlaceholderError from '../../UI/PlaceholderError';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import TextField from '../../UI/TextField';
|
||||
import SelectField from '../../UI/SelectField';
|
||||
import SelectOption from '../../UI/SelectOption';
|
||||
import { useOnlineStatus } from '../../Utils/OnlineStatus';
|
||||
import {
|
||||
type Leaderboard,
|
||||
type LeaderboardSortOption,
|
||||
type LeaderboardPlayerUnicityDisplayOption,
|
||||
type LeaderboardVisibilityOption,
|
||||
breakUuid,
|
||||
} from '../../Utils/GDevelopServices/Play';
|
||||
import LeaderboardContext from '../../Leaderboard/LeaderboardContext';
|
||||
import LeaderboardProvider from '../../Leaderboard/LeaderboardProvider';
|
||||
import Window from '../../Utils/Window';
|
||||
import LeaderboardEntriesTable from './LeaderboardEntriesTable';
|
||||
import { ResponsiveLineStackLayout } from '../../UI/Layout';
|
||||
import { useResponsiveWindowWidth } from '../../UI/Reponsive/ResponsiveWindowMeasurer';
|
||||
import { textEllipsisStyle } from '../../UI/TextEllipsis';
|
||||
import { shouldValidate } from '../../UI/KeyboardShortcuts/InteractionKeys';
|
||||
import Text from '../../UI/Text';
|
||||
import { GameRegistration } from '../GameRegistration';
|
||||
|
||||
type Props = {| onLoading: boolean => void, project?: gdProject |};
|
||||
type ContainerProps = {| ...Props, gameId: string |};
|
||||
|
||||
type ApiError = {|
|
||||
action:
|
||||
| 'entriesFetching'
|
||||
| 'entryDeletion'
|
||||
| 'leaderboardsFetching'
|
||||
| 'leaderboardNameUpdate'
|
||||
| 'leaderboardSortUpdate'
|
||||
| 'leaderboardPlayerUnicityDisplayChoiceUpdate'
|
||||
| 'leaderboardCreation'
|
||||
| 'leaderboardReset'
|
||||
| 'leaderboardDeletion',
|
||||
message: React.Node,
|
||||
itemId?: string,
|
||||
|};
|
||||
|
||||
const CenteredError = ({ children }: {| children: React.Node |}) => (
|
||||
<Column expand justifyContent="center" alignItems="center">
|
||||
<Line>{children}</Line>
|
||||
</Column>
|
||||
);
|
||||
|
||||
const styles = {
|
||||
leftColumn: { display: 'flex', flexDirection: 'column', flex: 1, padding: 5 },
|
||||
rightColumn: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 2,
|
||||
},
|
||||
leaderboardConfigurationPaper: { padding: 5, margin: 5 },
|
||||
leaderboardNameText: { ...textEllipsisStyle, width: 150 },
|
||||
leaderboardNameTextField: { width: 125, fontSize: 14 },
|
||||
};
|
||||
|
||||
export const LeaderboardAdmin = ({ onLoading, project }: Props) => {
|
||||
const isOnline = useOnlineStatus();
|
||||
const windowWidth = useResponsiveWindowWidth();
|
||||
const [isEditingName, setIsEditingName] = React.useState<boolean>(false);
|
||||
const [isRequestPending, setIsRequestPending] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [newName, setNewName] = React.useState<string>('');
|
||||
const [newNameError, setNewNameError] = React.useState<?string>(null);
|
||||
const newNameTextFieldRef = React.useRef<?TextField>(null);
|
||||
const [apiError, setApiError] = React.useState<?ApiError>(null);
|
||||
const [
|
||||
displayGameRegistration,
|
||||
setDisplayGameRegistration,
|
||||
] = React.useState<boolean>(false);
|
||||
|
||||
const {
|
||||
leaderboards,
|
||||
listLeaderboards,
|
||||
currentLeaderboard,
|
||||
createLeaderboard,
|
||||
selectLeaderboard,
|
||||
updateLeaderboard,
|
||||
resetLeaderboard,
|
||||
deleteLeaderboard,
|
||||
deleteLeaderboardEntry,
|
||||
displayOnlyBestEntry,
|
||||
setDisplayOnlyBestEntry,
|
||||
fetchLeaderboardEntries,
|
||||
browsing: { entries, goToNextPage, goToPreviousPage, goToFirstPage },
|
||||
} = React.useContext(LeaderboardContext);
|
||||
|
||||
const setIsLoading = React.useCallback(
|
||||
(yesOrNo: boolean) => {
|
||||
setIsRequestPending(yesOrNo);
|
||||
onLoading(yesOrNo);
|
||||
},
|
||||
[onLoading]
|
||||
);
|
||||
|
||||
const onUpdateLeaderboard = async (
|
||||
i18n: I18nType,
|
||||
payload: {|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}
|
||||
) => {
|
||||
setNewNameError(null);
|
||||
if (payload.name !== undefined && payload.name.length === 0) {
|
||||
setNewNameError(
|
||||
i18n._(
|
||||
t`Please enter a name that is at least one character long and 50 at most.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await updateLeaderboard(payload);
|
||||
if (payload.name) setIsEditingName(false);
|
||||
} catch (err) {
|
||||
console.error('An error occurred when updating leaderboard', err);
|
||||
setApiError({
|
||||
action: payload.name
|
||||
? 'leaderboardNameUpdate'
|
||||
: payload.sort
|
||||
? 'leaderboardSortUpdate'
|
||||
: 'leaderboardPlayerUnicityDisplayChoiceUpdate',
|
||||
message: payload.name ? (
|
||||
<Trans>
|
||||
An error occurred when updating the name of the leaderboard, please
|
||||
close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.sort ? (
|
||||
<Trans>
|
||||
An error occurred when updating the sort direction of the
|
||||
leaderboard, please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.visibility ? (
|
||||
<Trans>
|
||||
An error occurred when updating the visibility of the leaderboard,
|
||||
please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
An error occurred when updating the display choice of the
|
||||
leaderboard, please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onListLeaderboards = React.useCallback(
|
||||
() => {
|
||||
const fetchAndHandleError = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await listLeaderboards();
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
setDisplayGameRegistration(true);
|
||||
return;
|
||||
}
|
||||
console.error('An error occurred when fetching leaderboards', err);
|
||||
setApiError({
|
||||
action: 'leaderboardsFetching',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when fetching the leaderboards, please close
|
||||
the dialog and reopen it.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAndHandleError();
|
||||
},
|
||||
[setIsLoading, listLeaderboards]
|
||||
);
|
||||
|
||||
const onFetchLeaderboardEntries = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await fetchLeaderboardEntries();
|
||||
} catch (err) {
|
||||
console.error('An error occurred when fetching leaderboard entries', err);
|
||||
setApiError({
|
||||
action: 'entriesFetching',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when fetching the entries of the leaderboard,
|
||||
please try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateLeaderboard = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await createLeaderboard({
|
||||
name: 'New leaderboard',
|
||||
sort: 'ASC',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('An error occurred when creating leaderboard', err);
|
||||
setApiError({
|
||||
action: 'leaderboardCreation',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when creating a new leaderboard, please close the
|
||||
dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onResetLeaderboard = async (i18n: I18nType) => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
i18n._(
|
||||
t`All current entries will be deleted, are you sure you want to reset this leaderboard? This can't be undone.`
|
||||
)
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await resetLeaderboard();
|
||||
} catch (err) {
|
||||
console.error('An error occurred when resetting leaderboard', err);
|
||||
setApiError({
|
||||
action: 'leaderboardReset',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when resetting the leaderboard, please close the
|
||||
dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteLeaderboard = async (i18n: I18nType) => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
i18n._(
|
||||
t`Are you sure you want to delete this leaderboard and all of its entries? This can't be undone.`
|
||||
)
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await deleteLeaderboard();
|
||||
} catch (err) {
|
||||
console.error('An error occurred when deleting leaderboard', err);
|
||||
setApiError({
|
||||
action: 'leaderboardDeletion',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when deleting the leaderboard, please close the
|
||||
dialog, come back and try again.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteEntry = async (i18n: I18nType, entryId: string) => {
|
||||
const answer = Window.showConfirmDialog(
|
||||
i18n._(
|
||||
t`Are you sure you want to delete this entry? This can't be undone.`
|
||||
)
|
||||
);
|
||||
if (!answer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await deleteLeaderboardEntry(entryId);
|
||||
} catch (err) {
|
||||
console.error('An error occurred when deleting entry', err);
|
||||
setApiError({
|
||||
action: 'entryDeletion',
|
||||
message: (
|
||||
<Trans>
|
||||
An error occurred when deleting the entry, please try again.
|
||||
</Trans>
|
||||
),
|
||||
itemId: entryId,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (isEditingName && newNameTextFieldRef.current) {
|
||||
newNameTextFieldRef.current.focus();
|
||||
}
|
||||
},
|
||||
[isEditingName]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (leaderboards === null) {
|
||||
onListLeaderboards();
|
||||
}
|
||||
},
|
||||
[leaderboards, onListLeaderboards]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentLeaderboard) onFetchLeaderboardEntries();
|
||||
// This has to be executed on component mount to refresh entries on each admin opening
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
const onCopy = React.useCallback(
|
||||
() => {
|
||||
if (!currentLeaderboard) return;
|
||||
// TODO: use Clipboard.js, after it's been reworked to use this API and handle text.
|
||||
navigator.clipboard.writeText(currentLeaderboard.id);
|
||||
},
|
||||
[currentLeaderboard]
|
||||
);
|
||||
if (!isOnline) {
|
||||
return (
|
||||
<CenteredError>
|
||||
<PlaceholderError>
|
||||
<Trans>
|
||||
An internet connection is required to administrate your game's
|
||||
leaderboards.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (!!displayGameRegistration) {
|
||||
return (
|
||||
<CenteredError>
|
||||
<GameRegistration
|
||||
project={project}
|
||||
hideIfRegistered
|
||||
onGameRegistered={() => {
|
||||
setDisplayGameRegistration(false);
|
||||
onListLeaderboards();
|
||||
}}
|
||||
/>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (apiError && apiError.action === 'leaderboardCreation') {
|
||||
return (
|
||||
<CenteredError>
|
||||
<AlertMessage kind="error">{apiError.message}</AlertMessage>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (apiError && apiError.action === 'leaderboardsFetching') {
|
||||
return (
|
||||
<CenteredError>
|
||||
<PlaceholderError onRetry={onListLeaderboards} kind="error">
|
||||
{apiError.message}
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
if (leaderboards === null) {
|
||||
if (isRequestPending) return <PlaceholderLoader />;
|
||||
|
||||
return (
|
||||
<CenteredError>
|
||||
<PlaceholderError onRetry={onListLeaderboards} kind="error">
|
||||
<Trans>
|
||||
An error occurred when retrieving leaderboards, please try again
|
||||
later.
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
);
|
||||
}
|
||||
|
||||
if (!!leaderboards && leaderboards.length === 0)
|
||||
return (
|
||||
<Line noMargin expand justifyContent="center" alignItems="center">
|
||||
<EmptyPlaceholder
|
||||
title={<Trans>Create your game's first leaderboard</Trans>}
|
||||
description={<Trans>Leaderboards help retain your players</Trans>}
|
||||
actionLabel={<Trans>Create a leaderboard</Trans>}
|
||||
onAdd={() => {
|
||||
onCreateLeaderboard();
|
||||
}}
|
||||
isLoading={isRequestPending}
|
||||
/>
|
||||
</Line>
|
||||
);
|
||||
|
||||
const getLeaderboardDescription = (
|
||||
i18n: I18nType,
|
||||
currentLeaderboard: Leaderboard
|
||||
) => [
|
||||
{
|
||||
key: 'name',
|
||||
avatar: <Label />,
|
||||
text: isEditingName ? (
|
||||
<Line alignItems="center" expand noMargin>
|
||||
<TextField
|
||||
ref={newNameTextFieldRef}
|
||||
margin="none"
|
||||
style={styles.leaderboardNameTextField}
|
||||
maxLength={50}
|
||||
value={newName}
|
||||
errorText={newNameError}
|
||||
onChange={(e, text) => setNewName(text)}
|
||||
onKeyPress={event => {
|
||||
if (shouldValidate(event) && !isRequestPending) {
|
||||
onUpdateLeaderboard(i18n, { name: newName });
|
||||
}
|
||||
}}
|
||||
disabled={isRequestPending}
|
||||
/>
|
||||
{!isRequestPending && (
|
||||
<>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
tooltip={t`Cancel`}
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => {
|
||||
setIsEditingName(false);
|
||||
}}
|
||||
>
|
||||
<Cancel />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Line>
|
||||
) : (
|
||||
<Tooltip title={currentLeaderboard.name}>
|
||||
<Text size="body2" style={styles.leaderboardNameText}>
|
||||
{currentLeaderboard.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardNameUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (isEditingName) {
|
||||
onUpdateLeaderboard(i18n, { name: newName });
|
||||
} else {
|
||||
setNewName(currentLeaderboard.name);
|
||||
setIsEditingName(true);
|
||||
}
|
||||
}}
|
||||
tooltip={isEditingName ? t`Save` : t`Rename`}
|
||||
disabled={isRequestPending}
|
||||
edge="end"
|
||||
>
|
||||
{isEditingName ? (
|
||||
isRequestPending ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Save />
|
||||
)
|
||||
) : (
|
||||
<Edit />
|
||||
)}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'id',
|
||||
avatar: <Fingerprint />,
|
||||
text: (
|
||||
<Tooltip title={currentLeaderboard.id}>
|
||||
<Text size="body2">{breakUuid(currentLeaderboard.id)}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText: null,
|
||||
secondaryAction: (
|
||||
<IconButton onClick={onCopy} tooltip={t`Copy`} edge="end">
|
||||
<Copy />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'startDatetime',
|
||||
avatar: <Today />,
|
||||
text: (
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
t`Date from which entries are taken into account: ${i18n.date(
|
||||
currentLeaderboard.startDatetime,
|
||||
{
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}
|
||||
)}`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
{i18n.date(currentLeaderboard.startDatetime)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardReset' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={() => onResetLeaderboard(i18n)}
|
||||
tooltip={t`Reset leaderboard`}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<Update />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sort',
|
||||
avatar: <Sort />,
|
||||
text: (
|
||||
<Text size="body2">
|
||||
{currentLeaderboard.sort === 'ASC' ? (
|
||||
<Trans>Lower is better</Trans>
|
||||
) : (
|
||||
<Trans>Higher is better</Trans>
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardSortUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await onUpdateLeaderboard(i18n, {
|
||||
sort: currentLeaderboard.sort === 'ASC' ? 'DESC' : 'ASC',
|
||||
});
|
||||
}}
|
||||
tooltip={t`Change sort direction`}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<SwapVertical />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visibility',
|
||||
avatar:
|
||||
currentLeaderboard.visibility === 'HIDDEN' ? (
|
||||
<VisibilityOff />
|
||||
) : (
|
||||
<Visibility />
|
||||
),
|
||||
text: (
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
currentLeaderboard.visibility === 'HIDDEN'
|
||||
? t`Anyone with the link can see it, but it is not listed in your game's leaderboards.`
|
||||
: t`Anyone can access it.`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
{currentLeaderboard.visibility === 'HIDDEN' ? (
|
||||
<Trans>Not visible</Trans>
|
||||
) : (
|
||||
<Trans>Public</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError && apiError.action === 'leaderboardVisibilityUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await onUpdateLeaderboard(i18n, {
|
||||
visibility:
|
||||
currentLeaderboard.visibility === 'HIDDEN'
|
||||
? 'PUBLIC'
|
||||
: 'HIDDEN',
|
||||
});
|
||||
}}
|
||||
tooltip={
|
||||
currentLeaderboard.visibility === 'HIDDEN'
|
||||
? t`Make the leaderboard public`
|
||||
: t`Hide the leaderboard`
|
||||
}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<Loop />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'playerUnicityDisplayChoice',
|
||||
avatar: <PeopleAlt />,
|
||||
text: (
|
||||
<SelectField
|
||||
fullWidth
|
||||
margin="none"
|
||||
value={currentLeaderboard.playerUnicityDisplayChoice}
|
||||
onChange={(e, i, value) => {
|
||||
onUpdateLeaderboard(i18n, {
|
||||
// $FlowFixMe
|
||||
playerUnicityDisplayChoice: value,
|
||||
});
|
||||
}}
|
||||
disabled={isRequestPending || isEditingName}
|
||||
inputStyle={{ fontSize: 14 }}
|
||||
helperMarkdownText={
|
||||
currentLeaderboard.playerUnicityDisplayChoice === 'FREE'
|
||||
? i18n._(
|
||||
t`Users can chose to see only players' best entries or not.`
|
||||
)
|
||||
: currentLeaderboard.playerUnicityDisplayChoice ===
|
||||
'PREFER_UNIQUE'
|
||||
? i18n._(t`Only player's best entries are displayed.`)
|
||||
: i18n._(t`All entries are displayed.`)
|
||||
}
|
||||
>
|
||||
<SelectOption
|
||||
key={'free'}
|
||||
value={'FREE'}
|
||||
primaryText={i18n._(t`Let the user select`)}
|
||||
/>
|
||||
<SelectOption
|
||||
key={'prefer-unique'}
|
||||
value={'PREFER_UNIQUE'}
|
||||
primaryText={i18n._(t`Only best entry`)}
|
||||
/>
|
||||
<SelectOption
|
||||
key={'prefer-non-unique'}
|
||||
value={'PREFER_NON_UNIQUE'}
|
||||
primaryText={i18n._(t`All entries`)}
|
||||
/>
|
||||
</SelectField>
|
||||
),
|
||||
secondaryText:
|
||||
apiError &&
|
||||
apiError.action === 'leaderboardPlayerUnicityDisplayChoiceUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ResponsiveLineStackLayout noMargin expand noColumnMargin>
|
||||
<div style={styles.leftColumn}>
|
||||
<Paper elevation={5} style={styles.leaderboardConfigurationPaper}>
|
||||
<Column>
|
||||
<Line>
|
||||
{currentLeaderboard && leaderboards ? (
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>Leaderboard name</Trans>}
|
||||
value={currentLeaderboard.id}
|
||||
onChange={(e, i, leaderboardId) => {
|
||||
selectLeaderboard(leaderboardId);
|
||||
}}
|
||||
>
|
||||
{leaderboards.map(leaderboard => (
|
||||
<SelectOption
|
||||
key={leaderboard.id}
|
||||
value={leaderboard.id}
|
||||
primaryText={leaderboard.name}
|
||||
/>
|
||||
))}
|
||||
</SelectField>
|
||||
) : null}
|
||||
<IconButton
|
||||
onClick={onCreateLeaderboard}
|
||||
disabled={isEditingName || isRequestPending}
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</Line>
|
||||
{currentLeaderboard ? (
|
||||
<>
|
||||
<List>
|
||||
{getLeaderboardDescription(i18n, currentLeaderboard).map(
|
||||
(item, index) => (
|
||||
<React.Fragment key={`fragment-${item.key}`}>
|
||||
{index > 0 ? (
|
||||
<Divider
|
||||
key={`divider-${item.key}`}
|
||||
component="li"
|
||||
/>
|
||||
) : null}
|
||||
<ListItem key={item.key} disableGutters>
|
||||
<ListItemAvatar>
|
||||
<Avatar>{item.avatar}</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
disableTypography
|
||||
secondary={item.secondaryText}
|
||||
>
|
||||
{item.text}
|
||||
</ListItemText>
|
||||
{item.secondaryAction ? (
|
||||
<ListItemSecondaryAction>
|
||||
{item.secondaryAction}
|
||||
</ListItemSecondaryAction>
|
||||
) : null}
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</List>
|
||||
<Line>
|
||||
<RaisedButton
|
||||
icon={<Delete />}
|
||||
label={<Trans>Delete</Trans>}
|
||||
disabled={isRequestPending || isEditingName}
|
||||
onClick={() => onDeleteLeaderboard(i18n)}
|
||||
/>
|
||||
</Line>
|
||||
{apiError && apiError.action === 'leaderboardDeletion' ? (
|
||||
<PlaceholderError kind="error">
|
||||
{apiError.message}
|
||||
</PlaceholderError>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Column>
|
||||
</Paper>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...styles.rightColumn,
|
||||
paddingLeft: windowWidth === 'small' ? 0 : 20,
|
||||
}}
|
||||
>
|
||||
<Line alignItems="center" justifyContent="flex-end">
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
t`When checked, will only display the best score of each player (only for the display below).`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
<Trans>Player best entry</Trans>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
color="primary"
|
||||
size="small"
|
||||
checked={displayOnlyBestEntry}
|
||||
onClick={() => setDisplayOnlyBestEntry(!displayOnlyBestEntry)}
|
||||
/>
|
||||
<Divider orientation="vertical" />
|
||||
<IconButton
|
||||
onClick={onFetchLeaderboardEntries}
|
||||
disabled={isRequestPending || isEditingName}
|
||||
tooltip={t`Refresh`}
|
||||
size="small"
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
<Spacer />
|
||||
</Line>
|
||||
{apiError && apiError.action === 'entriesFetching' ? (
|
||||
<CenteredError>
|
||||
<PlaceholderError
|
||||
onRetry={onFetchLeaderboardEntries}
|
||||
kind="error"
|
||||
>
|
||||
{apiError.message}
|
||||
</PlaceholderError>
|
||||
</CenteredError>
|
||||
) : (
|
||||
<LeaderboardEntriesTable
|
||||
entries={entries}
|
||||
onDeleteEntry={entryId => onDeleteEntry(i18n, entryId)}
|
||||
isLoading={isRequestPending || isEditingName}
|
||||
navigation={{
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
goToFirstPage,
|
||||
}}
|
||||
erroredEntry={
|
||||
apiError &&
|
||||
apiError.action === 'entryDeletion' &&
|
||||
apiError.itemId
|
||||
? { entryId: apiError.itemId, message: apiError.message }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveLineStackLayout>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
||||
|
||||
const LeaderboardAdminContainer = ({
|
||||
gameId,
|
||||
...otherProps
|
||||
}: ContainerProps) => (
|
||||
<LeaderboardProvider gameId={gameId}>
|
||||
<LeaderboardAdmin {...otherProps} />
|
||||
</LeaderboardProvider>
|
||||
);
|
||||
|
||||
export default LeaderboardAdminContainer;
|
325
newIDE/app/src/GameDashboard/PublicGameProperties.js
Normal file
325
newIDE/app/src/GameDashboard/PublicGameProperties.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import SemiControlledTextField from '../UI/SemiControlledTextField';
|
||||
import { UsersAutocomplete } from '../Utils/UsersAutocomplete';
|
||||
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import Checkbox from '../UI/Checkbox';
|
||||
import SelectField from '../UI/SelectField';
|
||||
import SelectOption from '../UI/SelectOption';
|
||||
import { t } from '@lingui/macro';
|
||||
import SemiControlledMultiAutoComplete from '../UI/SemiControlledMultiAutoComplete';
|
||||
import {
|
||||
allGameCategories,
|
||||
getCategoryName,
|
||||
} from '../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
import BackgroundText from '../UI/BackgroundText';
|
||||
import AlertMessage from '../UI/AlertMessage';
|
||||
import { GameThumbnail } from './GameThumbnail';
|
||||
|
||||
const isCyrillic = (text: string) =>
|
||||
/[БГДЖЗИЙЛПФЦЧШЩЫЭЮЯбвгджзийклмнптфцчшщыэюя]/.test(text);
|
||||
const cyrillicToLatinMapping = require('./CyrillicToLatin.json');
|
||||
|
||||
export const cleanUpGameSlug = (gameSlug: string) => {
|
||||
let latinGameSlug = gameSlug;
|
||||
if (isCyrillic(gameSlug)) {
|
||||
latinGameSlug = gameSlug
|
||||
.split('')
|
||||
.map(function(char) {
|
||||
const latin = cyrillicToLatinMapping[char];
|
||||
return latin === undefined ? char : latin;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
return latinGameSlug
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]/g, '-')
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
// Properties visible in the project properties and game dialogs.
|
||||
setName: string => void,
|
||||
name: string,
|
||||
setDescription: string => void,
|
||||
description: ?string,
|
||||
setAuthorIds: (string[]) => void,
|
||||
authorIds: string[],
|
||||
setOrientation: string => void,
|
||||
orientation: string,
|
||||
// Properties only visible in the game dialog.
|
||||
setCategories?: (string[]) => void,
|
||||
categories?: string[],
|
||||
setOwnerIds?: (string[]) => void,
|
||||
ownerIds?: string[],
|
||||
setPlayableWithKeyboard?: boolean => void,
|
||||
playWithKeyboard?: boolean,
|
||||
setPlayableWithGamepad?: boolean => void,
|
||||
playWithGamepad?: boolean,
|
||||
setPlayableWithMobile?: boolean => void,
|
||||
playWithMobile?: boolean,
|
||||
userSlug?: string,
|
||||
setUserSlug?: string => void,
|
||||
gameSlug?: string,
|
||||
setGameSlug?: string => void,
|
||||
setDiscoverable?: boolean => void,
|
||||
discoverable?: boolean,
|
||||
displayThumbnail?: boolean,
|
||||
thumbnailUrl?: string,
|
||||
|};
|
||||
|
||||
export function PublicGameProperties({
|
||||
project,
|
||||
setName,
|
||||
name,
|
||||
categories,
|
||||
setCategories,
|
||||
setDescription,
|
||||
description,
|
||||
setAuthorIds,
|
||||
authorIds,
|
||||
setOwnerIds,
|
||||
ownerIds,
|
||||
setPlayableWithKeyboard,
|
||||
playWithKeyboard,
|
||||
setPlayableWithGamepad,
|
||||
playWithGamepad,
|
||||
setPlayableWithMobile,
|
||||
playWithMobile,
|
||||
setOrientation,
|
||||
orientation,
|
||||
userSlug,
|
||||
setUserSlug,
|
||||
gameSlug,
|
||||
setGameSlug,
|
||||
setDiscoverable,
|
||||
discoverable,
|
||||
displayThumbnail,
|
||||
thumbnailUrl,
|
||||
}: Props) {
|
||||
const [categoryInput, setCategoryInput] = React.useState('');
|
||||
const { profile } = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ColumnStackLayout noMargin>
|
||||
<ResponsiveLineStackLayout noMargin>
|
||||
{displayThumbnail && (
|
||||
<>
|
||||
<Column noMargin>
|
||||
<GameThumbnail
|
||||
gameName={project.getName()}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
/>
|
||||
</Column>
|
||||
<Spacer />
|
||||
</>
|
||||
)}
|
||||
<ColumnStackLayout noMargin expand>
|
||||
<SemiControlledTextField
|
||||
floatingLabelText={<Trans>Game name</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
autoFocus
|
||||
/>
|
||||
{setCategories && (
|
||||
<SemiControlledMultiAutoComplete
|
||||
hintText={t`Select a genre`}
|
||||
floatingLabelText={<Trans>Genres</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select up to 4 genres for the game to be visible on
|
||||
Liluo.io's categories pages!
|
||||
</Trans>
|
||||
}
|
||||
value={
|
||||
categories
|
||||
? categories.map(category => ({
|
||||
value: category,
|
||||
text: getCategoryName(category, i18n),
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onChange={(event, values) => {
|
||||
setCategories(
|
||||
values ? values.map(category => category.value) : []
|
||||
);
|
||||
}}
|
||||
inputValue={categoryInput}
|
||||
onInputChange={(event, value) => {
|
||||
setCategoryInput(value);
|
||||
}}
|
||||
dataSource={allGameCategories.map(category => ({
|
||||
value: category,
|
||||
text: getCategoryName(category, i18n),
|
||||
}))}
|
||||
fullWidth
|
||||
optionsLimit={4}
|
||||
/>
|
||||
)}
|
||||
{setDiscoverable && (
|
||||
<Checkbox
|
||||
label={<Trans>Make your game discoverable on Liluo.io</Trans>}
|
||||
checked={!!discoverable}
|
||||
onCheck={(e, checked) => setDiscoverable(checked)}
|
||||
/>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</ResponsiveLineStackLayout>
|
||||
{displayThumbnail && (
|
||||
<Line noMargin>
|
||||
<BackgroundText>
|
||||
<Trans>
|
||||
To update your thumbnail, go into your Game Settings > Icons
|
||||
and thumbnail, then create and publish a new build.
|
||||
</Trans>
|
||||
</BackgroundText>
|
||||
</Line>
|
||||
)}
|
||||
<SemiControlledTextField
|
||||
floatingLabelText={<Trans>Game description</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={description || ''}
|
||||
onChange={setDescription}
|
||||
autoFocus
|
||||
multiline
|
||||
rows={5}
|
||||
/>
|
||||
{setUserSlug && setGameSlug && (
|
||||
<>
|
||||
<Line>
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>User name in the game URL</Trans>}
|
||||
value={userSlug || ''}
|
||||
onChange={(e, i, value: string) => setUserSlug(value)}
|
||||
// It's disabled if one of the condition of SelectOption is false.
|
||||
disabled={
|
||||
!(
|
||||
profile &&
|
||||
profile.username &&
|
||||
userSlug &&
|
||||
(!profile || userSlug !== profile.username)
|
||||
)
|
||||
}
|
||||
>
|
||||
{profile && profile.username && (
|
||||
<SelectOption
|
||||
value={profile.username}
|
||||
primaryText={profile.username}
|
||||
/>
|
||||
)}
|
||||
{userSlug && (!profile || userSlug !== profile.username) && (
|
||||
<SelectOption value={userSlug} primaryText={userSlug} />
|
||||
)}
|
||||
</SelectField>
|
||||
<Spacer />
|
||||
<SemiControlledTextField
|
||||
disabled={
|
||||
!(
|
||||
userSlug &&
|
||||
userSlug.length &&
|
||||
profile &&
|
||||
profile.username
|
||||
)
|
||||
}
|
||||
floatingLabelText={<Trans>Game name in the game URL</Trans>}
|
||||
fullWidth
|
||||
type="text"
|
||||
value={
|
||||
userSlug && userSlug.length && profile && profile.username
|
||||
? gameSlug || ''
|
||||
: ''
|
||||
}
|
||||
onChange={gameSlug => setGameSlug(cleanUpGameSlug(gameSlug))}
|
||||
autoFocus
|
||||
/>
|
||||
</Line>
|
||||
{!(
|
||||
userSlug &&
|
||||
userSlug.length &&
|
||||
profile &&
|
||||
profile.username
|
||||
) && (
|
||||
<AlertMessage kind="info">
|
||||
<Trans>
|
||||
Usernames are required to choose a custom game URL.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<UsersAutocomplete
|
||||
userIds={authorIds}
|
||||
onChange={setAuthorIds}
|
||||
floatingLabelText={<Trans>Authors</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select the usernames of the authors of this project. They will
|
||||
be displayed in the selected order, if you publish this game as
|
||||
an example or in the community.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
{setOwnerIds && (
|
||||
<UsersAutocomplete
|
||||
userIds={ownerIds || []}
|
||||
onChange={setOwnerIds}
|
||||
floatingLabelText={<Trans>Owners</Trans>}
|
||||
helperText={
|
||||
<Trans>
|
||||
Select the usernames of the owners of this project to let them
|
||||
manage this game builds. Be aware that owners can revoke your
|
||||
ownership.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>Device orientation (for mobile)</Trans>}
|
||||
value={orientation}
|
||||
onChange={(e, i, value: string) => setOrientation(value)}
|
||||
>
|
||||
<SelectOption value="default" primaryText={t`Platform default`} />
|
||||
<SelectOption value="landscape" primaryText={t`Landscape`} />
|
||||
<SelectOption value="portrait" primaryText={t`Portrait`} />
|
||||
</SelectField>
|
||||
{setPlayableWithKeyboard &&
|
||||
setPlayableWithGamepad &&
|
||||
setPlayableWithMobile && (
|
||||
<React.Fragment>
|
||||
<Checkbox
|
||||
label={<Trans>Playable with a keyboard</Trans>}
|
||||
checked={!!playWithKeyboard}
|
||||
onCheck={(e, checked) => setPlayableWithKeyboard(checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Playable with a gamepad</Trans>}
|
||||
checked={!!playWithGamepad}
|
||||
onCheck={(e, checked) => setPlayableWithGamepad(checked)}
|
||||
/>
|
||||
<Checkbox
|
||||
label={<Trans>Playable on mobile</Trans>}
|
||||
checked={!!playWithMobile}
|
||||
onCheck={(e, checked) => setPlayableWithMobile(checked)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicGameProperties;
|
@@ -2,7 +2,7 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
import React from 'react';
|
||||
import PublicGameProperties from './PublicGameProperties';
|
||||
import { PublicGameProperties, cleanUpGameSlug } from './PublicGameProperties';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import {
|
||||
displayProjectErrorsBox,
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import Dialog from '../UI/Dialog';
|
||||
import { type PublicGame } from '../Utils/GDevelopServices/Game';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
|
||||
/**
|
||||
* Changes that are not stored in the Project.
|
||||
*/
|
||||
export type PartialGameChange = {|
|
||||
ownerIds: Array<string>,
|
||||
ownerIds?: Array<string>,
|
||||
userSlug?: string,
|
||||
gameSlug?: string,
|
||||
discoverable?: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
@@ -33,10 +37,10 @@ type PublicProjectProperties = {|
|
||||
orientation: string,
|
||||
|};
|
||||
|
||||
function applyPublicPropertiesToProject(
|
||||
export const applyPublicPropertiesToProject = (
|
||||
project: gdProject,
|
||||
newProperties: PublicProjectProperties
|
||||
) {
|
||||
) => {
|
||||
const t = str => str; //TODO
|
||||
const { name, authorIds, description, categories } = newProperties;
|
||||
project.setName(name);
|
||||
@@ -53,23 +57,25 @@ function applyPublicPropertiesToProject(
|
||||
project.setOrientation(newProperties.orientation);
|
||||
|
||||
return displayProjectErrorsBox(t, getProjectPropertiesErrors(t, project));
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
publicGame: PublicGame,
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onApply: (partialGameChange: PartialGameChange) => void,
|
||||
onApply: (partialGameChange: PartialGameChange) => Promise<void>,
|
||||
isLoading: boolean,
|
||||
|};
|
||||
|
||||
export const PublicGamePropertiesDialog = ({
|
||||
project,
|
||||
publicGame,
|
||||
open,
|
||||
onClose,
|
||||
onApply,
|
||||
isLoading,
|
||||
}: Props) => {
|
||||
const { profile } = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const publicGameAuthorIds = publicGame.authors.map(author => author.id);
|
||||
const publicGameOwnerIds = publicGame.owners.map(owner => owner.id);
|
||||
const [name, setName] = React.useState(publicGame.gameName);
|
||||
@@ -89,10 +95,17 @@ export const PublicGamePropertiesDialog = ({
|
||||
publicGame.playWithMobile
|
||||
);
|
||||
const [orientation, setOrientation] = React.useState(publicGame.orientation);
|
||||
const [userSlug, setUserSlug] = React.useState(
|
||||
publicGame.userSlug || (profile && profile.username) || ''
|
||||
);
|
||||
const [gameSlug, setGameSlug] = React.useState(
|
||||
publicGame.gameSlug || cleanUpGameSlug(publicGame.gameName)
|
||||
);
|
||||
const [discoverable, setDiscoverable] = React.useState(
|
||||
publicGame.discoverable
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const onSave = () => {
|
||||
const onSave = async () => {
|
||||
if (
|
||||
applyPublicPropertiesToProject(project, {
|
||||
name,
|
||||
@@ -105,7 +118,7 @@ export const PublicGamePropertiesDialog = ({
|
||||
orientation: orientation || 'default',
|
||||
})
|
||||
) {
|
||||
onApply({ ownerIds });
|
||||
await onApply({ ownerIds, userSlug, gameSlug, discoverable });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,12 +128,14 @@ export const PublicGamePropertiesDialog = ({
|
||||
key="back"
|
||||
primary={false}
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
<RaisedButton
|
||||
label={<Trans>Save</Trans>}
|
||||
primary
|
||||
onClick={onSave}
|
||||
key="save"
|
||||
disabled={isLoading}
|
||||
/>,
|
||||
];
|
||||
|
||||
@@ -130,7 +145,7 @@ export const PublicGamePropertiesDialog = ({
|
||||
onRequestClose={onClose}
|
||||
actions={actions}
|
||||
cannotBeDismissed={false}
|
||||
open={open}
|
||||
open
|
||||
>
|
||||
<PublicGameProperties
|
||||
name={name}
|
||||
@@ -152,6 +167,14 @@ export const PublicGamePropertiesDialog = ({
|
||||
playWithMobile={playWithMobile}
|
||||
setOrientation={setOrientation}
|
||||
orientation={orientation}
|
||||
setUserSlug={setUserSlug}
|
||||
userSlug={userSlug}
|
||||
setGameSlug={setGameSlug}
|
||||
gameSlug={gameSlug}
|
||||
setDiscoverable={setDiscoverable}
|
||||
discoverable={discoverable}
|
||||
displayThumbnail
|
||||
thumbnailUrl={publicGame.thumbnailUrl}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
@@ -83,6 +83,12 @@ const jsExtensions = [
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Firebase/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'Leaderboards',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Leaderboards/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'DialogueTree',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
@@ -147,6 +153,12 @@ const jsExtensions = [
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Screenshot/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
{
|
||||
name: 'TextInput',
|
||||
// $FlowExpectedError - this path is ignored for Flow.
|
||||
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/TextInput/JsExtension.js'),
|
||||
objectsRenderingServiceModules: {},
|
||||
},
|
||||
];
|
||||
|
||||
type MakeExtensionsLoaderArguments = {|
|
||||
|
@@ -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: '',
|
||||
};
|
||||
|
65
newIDE/app/src/Leaderboard/LeaderboardContext.js
Normal file
65
newIDE/app/src/Leaderboard/LeaderboardContext.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type Leaderboard,
|
||||
type LeaderboardSortOption,
|
||||
type LeaderboardDisplayData,
|
||||
type LeaderboardPlayerUnicityDisplayOption,
|
||||
type LeaderboardVisibilityOption,
|
||||
} from '../Utils/GDevelopServices/Play';
|
||||
|
||||
export type LeaderboardState = {|
|
||||
leaderboards: ?Array<Leaderboard>,
|
||||
currentLeaderboard: ?Leaderboard,
|
||||
displayOnlyBestEntry: boolean,
|
||||
browsing: {|
|
||||
entries: ?Array<LeaderboardDisplayData>,
|
||||
goToNextPage: ?() => Promise<void>,
|
||||
goToPreviousPage: ?() => Promise<void>,
|
||||
goToFirstPage: ?() => Promise<void>,
|
||||
|},
|
||||
createLeaderboard: ({|
|
||||
name: string,
|
||||
sort: LeaderboardSortOption,
|
||||
|}) => Promise<?Leaderboard>,
|
||||
listLeaderboards: () => Promise<void>,
|
||||
selectLeaderboard: (id: string) => void,
|
||||
setDisplayOnlyBestEntry: boolean => void,
|
||||
updateLeaderboard: ({|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}) => Promise<void>,
|
||||
resetLeaderboard: () => Promise<void>,
|
||||
deleteLeaderboard: () => Promise<void>,
|
||||
deleteLeaderboardEntry: (entryId: string) => Promise<void>,
|
||||
fetchLeaderboardEntries: () => Promise<void>,
|
||||
|};
|
||||
|
||||
export const initialLeaderboardState = {
|
||||
leaderboards: null,
|
||||
currentLeaderboard: null,
|
||||
displayOnlyBestEntry: false,
|
||||
browsing: {
|
||||
entries: null,
|
||||
goToNextPage: null,
|
||||
goToPreviousPage: null,
|
||||
goToFirstPage: null,
|
||||
},
|
||||
createLeaderboard: async () => null,
|
||||
listLeaderboards: async () => {},
|
||||
selectLeaderboard: () => {},
|
||||
setDisplayOnlyBestEntry: () => {},
|
||||
updateLeaderboard: async () => {},
|
||||
resetLeaderboard: async () => {},
|
||||
deleteLeaderboard: async () => {},
|
||||
deleteLeaderboardEntry: async entryId => {},
|
||||
fetchLeaderboardEntries: async () => {},
|
||||
};
|
||||
|
||||
const LeaderboardContext = React.createContext<LeaderboardState>(
|
||||
initialLeaderboardState
|
||||
);
|
||||
|
||||
export default LeaderboardContext;
|
48
newIDE/app/src/Leaderboard/LeaderboardDialog.js
Normal file
48
newIDE/app/src/Leaderboard/LeaderboardDialog.js
Normal file
@@ -0,0 +1,48 @@
|
||||
//@flow
|
||||
import React from 'react';
|
||||
import { LeaderboardAdmin } from '../GameDashboard/LeaderboardAdmin';
|
||||
|
||||
import Dialog from '../UI/Dialog';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import HelpButton from '../UI/HelpButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
|
||||
type Props = {|
|
||||
onClose: () => void,
|
||||
open: boolean,
|
||||
project: gdProject,
|
||||
|};
|
||||
|
||||
const LeaderboardDialog = ({ onClose, open, project }: Props) => {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
return (
|
||||
<Dialog
|
||||
actions={[
|
||||
<FlatButton
|
||||
label={<Trans>Close</Trans>}
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
key={'Close'}
|
||||
/>,
|
||||
]}
|
||||
secondaryActions={[
|
||||
<HelpButton
|
||||
key="help"
|
||||
helpPagePath="/interface/games-dashboard/leaderboard-administration"
|
||||
/>,
|
||||
]}
|
||||
open={open}
|
||||
cannotBeDismissed={true}
|
||||
onRequestClose={() => {
|
||||
if (!isLoading) onClose();
|
||||
}}
|
||||
title={<Trans>Leaderboards</Trans>}
|
||||
flexBody
|
||||
fullHeight
|
||||
>
|
||||
<LeaderboardAdmin onLoading={setIsLoading} project={project} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardDialog;
|
430
newIDE/app/src/Leaderboard/LeaderboardProvider.js
Normal file
430
newIDE/app/src/Leaderboard/LeaderboardProvider.js
Normal file
@@ -0,0 +1,430 @@
|
||||
// @flow
|
||||
|
||||
import * as React from 'react';
|
||||
import LeaderboardContext from './LeaderboardContext';
|
||||
import {
|
||||
type Leaderboard,
|
||||
type LeaderboardEntry,
|
||||
type LeaderboardExtremePlayerScore,
|
||||
type LeaderboardSortOption,
|
||||
type LeaderboardVisibilityOption,
|
||||
type LeaderboardPlayerUnicityDisplayOption,
|
||||
type LeaderboardDisplayData,
|
||||
createLeaderboard as doCreateLeaderboard,
|
||||
updateLeaderboard as doUpdateLeaderboard,
|
||||
resetLeaderboard as doResetLeaderboard,
|
||||
deleteLeaderboardEntry as doDeleteLeaderboardEntry,
|
||||
deleteLeaderboard as doDeleteLeaderboard,
|
||||
extractExtremeScoreDisplayData,
|
||||
extractEntryDisplayData,
|
||||
listLeaderboardEntries,
|
||||
listGameLeaderboards,
|
||||
} from '../Utils/GDevelopServices/Play';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
|
||||
type Props = {| gameId: string, children: React.Node |};
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const shouldDisplayOnlyBestEntries = (leaderboard: Leaderboard) =>
|
||||
leaderboard.playerUnicityDisplayChoice === 'PREFER_UNIQUE';
|
||||
|
||||
type ReducerState = {|
|
||||
currentLeaderboardId: ?string,
|
||||
currentLeaderboard: ?Leaderboard,
|
||||
leaderboardsByIds: ?{| [string]: Leaderboard |},
|
||||
displayOnlyBestEntry: boolean,
|
||||
entries: ?Array<LeaderboardDisplayData>,
|
||||
mapPageIndexToUri: {| [number]: string |},
|
||||
pageIndex: number,
|
||||
|};
|
||||
|
||||
type ReducerAction =
|
||||
| {| type: 'SET_LEADERBOARDS', payload: ?Array<Leaderboard> |}
|
||||
| {| type: 'SET_ENTRIES', payload: ?Array<LeaderboardDisplayData> |}
|
||||
| {| type: 'SET_NEXT_PAGE_URI', payload: string |}
|
||||
| {| type: 'SELECT_LEADERBOARD', payload: string |}
|
||||
| {| type: 'SET_PAGE_INDEX', payload: number |}
|
||||
| {| type: 'PURGE_NAVIGATION' |}
|
||||
| {| type: 'CHANGE_DISPLAY_ONLY_BEST_ENTRY', payload: boolean |}
|
||||
| {| type: 'UPDATE_OR_CREATE_LEADERBOARD', payload: Leaderboard |}
|
||||
| {| type: 'REMOVE_LEADERBOARD', payload: string |};
|
||||
|
||||
const reducer = (state: ReducerState, action: ReducerAction): ReducerState => {
|
||||
switch (action.type) {
|
||||
case 'SET_LEADERBOARDS':
|
||||
const leaderboards = action.payload;
|
||||
if (!leaderboards)
|
||||
return {
|
||||
...state,
|
||||
leaderboardsByIds: null,
|
||||
currentLeaderboardId: null,
|
||||
currentLeaderboard: null,
|
||||
};
|
||||
|
||||
const leaderboardsByIds = leaderboards.reduce((acc, leaderboard) => {
|
||||
acc[leaderboard.id] = leaderboard;
|
||||
return acc;
|
||||
}, {});
|
||||
const shouldDefineCurrentLeaderboardIfNoneSelected =
|
||||
!state.currentLeaderboard && leaderboards && leaderboards.length > 0;
|
||||
const newCurrentLeaderboard = shouldDefineCurrentLeaderboardIfNoneSelected
|
||||
? leaderboards[0]
|
||||
: state.currentLeaderboard;
|
||||
return {
|
||||
...state,
|
||||
leaderboardsByIds,
|
||||
displayOnlyBestEntry: newCurrentLeaderboard
|
||||
? shouldDisplayOnlyBestEntries(newCurrentLeaderboard)
|
||||
: false,
|
||||
currentLeaderboardId: newCurrentLeaderboard
|
||||
? newCurrentLeaderboard.id
|
||||
: null,
|
||||
currentLeaderboard: newCurrentLeaderboard,
|
||||
};
|
||||
case 'SET_ENTRIES':
|
||||
return {
|
||||
...state,
|
||||
entries: action.payload,
|
||||
};
|
||||
case 'PURGE_NAVIGATION':
|
||||
return {
|
||||
...state,
|
||||
entries: null,
|
||||
pageIndex: 0,
|
||||
mapPageIndexToUri: {},
|
||||
};
|
||||
case 'SET_NEXT_PAGE_URI':
|
||||
const nextPageIndex = state.pageIndex + 1;
|
||||
return {
|
||||
...state,
|
||||
mapPageIndexToUri: {
|
||||
...state.mapPageIndexToUri,
|
||||
[nextPageIndex]: action.payload,
|
||||
},
|
||||
};
|
||||
case 'SET_PAGE_INDEX':
|
||||
return {
|
||||
...state,
|
||||
pageIndex: action.payload,
|
||||
};
|
||||
case 'SELECT_LEADERBOARD':
|
||||
if (!state.leaderboardsByIds) return state;
|
||||
const leaderboard = state.leaderboardsByIds[action.payload];
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(leaderboard),
|
||||
currentLeaderboardId: leaderboard.id,
|
||||
currentLeaderboard: leaderboard,
|
||||
};
|
||||
case 'CHANGE_DISPLAY_ONLY_BEST_ENTRY':
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: action.payload,
|
||||
};
|
||||
case 'UPDATE_OR_CREATE_LEADERBOARD':
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(action.payload),
|
||||
leaderboardsByIds: {
|
||||
...state.leaderboardsByIds,
|
||||
[action.payload.id]: action.payload,
|
||||
},
|
||||
currentLeaderboardId: action.payload.id,
|
||||
currentLeaderboard: action.payload,
|
||||
};
|
||||
case 'REMOVE_LEADERBOARD':
|
||||
const newLeaderboardsByIds = { ...state.leaderboardsByIds };
|
||||
delete newLeaderboardsByIds[action.payload];
|
||||
const leaderboardsIds = Object.keys(newLeaderboardsByIds);
|
||||
if (leaderboardsIds.length === 0) {
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: false,
|
||||
leaderboardsByIds: newLeaderboardsByIds,
|
||||
currentLeaderboard: null,
|
||||
currentLeaderboardId: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
displayOnlyBestEntry: shouldDisplayOnlyBestEntries(
|
||||
newLeaderboardsByIds[leaderboardsIds[0]]
|
||||
),
|
||||
leaderboardsByIds: newLeaderboardsByIds,
|
||||
currentLeaderboard: newLeaderboardsByIds[leaderboardsIds[0]],
|
||||
currentLeaderboardId: leaderboardsIds[0],
|
||||
};
|
||||
default: {
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const LeaderboardProvider = ({ gameId, children }: Props) => {
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
// Ensure that only one request for leaderboards list is sent at the same time.
|
||||
const isListingLeaderboards = React.useRef(false);
|
||||
|
||||
const [
|
||||
{
|
||||
currentLeaderboardId,
|
||||
currentLeaderboard,
|
||||
leaderboardsByIds,
|
||||
displayOnlyBestEntry,
|
||||
entries,
|
||||
mapPageIndexToUri,
|
||||
pageIndex,
|
||||
},
|
||||
dispatch,
|
||||
] = React.useReducer<ReducerState, ReducerAction>(reducer, {
|
||||
currentLeaderboardId: null,
|
||||
currentLeaderboard: null,
|
||||
leaderboardsByIds: null,
|
||||
displayOnlyBestEntry: false,
|
||||
entries: null,
|
||||
mapPageIndexToUri: {},
|
||||
pageIndex: 0,
|
||||
});
|
||||
|
||||
const listLeaderboards = React.useCallback(
|
||||
async () => {
|
||||
if (!isListingLeaderboards.current) {
|
||||
isListingLeaderboards.current = true;
|
||||
try {
|
||||
dispatch({ type: 'SET_LEADERBOARDS', payload: null });
|
||||
const fetchedLeaderboards = await listGameLeaderboards(
|
||||
authenticatedUser,
|
||||
gameId
|
||||
);
|
||||
if (!fetchedLeaderboards) return;
|
||||
fetchedLeaderboards.sort((a, b) => a.name.localeCompare(b.name));
|
||||
dispatch({
|
||||
type: 'SET_LEADERBOARDS',
|
||||
payload: fetchedLeaderboards,
|
||||
});
|
||||
} finally {
|
||||
isListingLeaderboards.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[gameId, authenticatedUser]
|
||||
);
|
||||
|
||||
const createLeaderboard = React.useCallback(
|
||||
async (creationPayload: {|
|
||||
name: string,
|
||||
sort: LeaderboardSortOption,
|
||||
|}) => {
|
||||
dispatch({ type: 'SET_ENTRIES', payload: null });
|
||||
const newLeaderboard = await doCreateLeaderboard(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
creationPayload
|
||||
);
|
||||
if (!newLeaderboard) return;
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_OR_CREATE_LEADERBOARD',
|
||||
payload: newLeaderboard,
|
||||
});
|
||||
},
|
||||
[gameId, authenticatedUser]
|
||||
);
|
||||
|
||||
const fetchEntries = React.useCallback(
|
||||
async (options?: {| uri?: ?string |}) => {
|
||||
if (!currentLeaderboardId) return;
|
||||
|
||||
const uriToUse = options && options.uri ? options.uri : null;
|
||||
|
||||
dispatch({ type: 'SET_ENTRIES', payload: null });
|
||||
const data = await listLeaderboardEntries(gameId, currentLeaderboardId, {
|
||||
pageSize,
|
||||
onlyBestEntry: displayOnlyBestEntry,
|
||||
forceUri: uriToUse,
|
||||
});
|
||||
if (!data) return;
|
||||
const fetchedEntries:
|
||||
| LeaderboardEntry[]
|
||||
| LeaderboardExtremePlayerScore[] = data.entries;
|
||||
|
||||
if (data.nextPageUri) {
|
||||
dispatch({ type: 'SET_NEXT_PAGE_URI', payload: data.nextPageUri });
|
||||
}
|
||||
|
||||
let entriesToDisplay: LeaderboardDisplayData[] = [];
|
||||
if (displayOnlyBestEntry) {
|
||||
entriesToDisplay = fetchedEntries.map(entry =>
|
||||
// $FlowFixMe
|
||||
extractExtremeScoreDisplayData(entry)
|
||||
);
|
||||
} else {
|
||||
entriesToDisplay = fetchedEntries.map(entry =>
|
||||
// $FlowFixMe
|
||||
extractEntryDisplayData(entry)
|
||||
);
|
||||
}
|
||||
dispatch({ type: 'SET_ENTRIES', payload: entriesToDisplay });
|
||||
},
|
||||
[currentLeaderboardId, displayOnlyBestEntry, gameId]
|
||||
);
|
||||
|
||||
const selectLeaderboard = React.useCallback((leaderboardId: string) => {
|
||||
dispatch({ type: 'SELECT_LEADERBOARD', payload: leaderboardId });
|
||||
}, []);
|
||||
|
||||
const setDisplayOnlyBestEntry = React.useCallback((newValue: boolean) => {
|
||||
dispatch({ type: 'CHANGE_DISPLAY_ONLY_BEST_ENTRY', payload: newValue });
|
||||
}, []);
|
||||
|
||||
const updateLeaderboard = async (attributes: {|
|
||||
name?: string,
|
||||
sort?: LeaderboardSortOption,
|
||||
playerUnicityDisplayChoice?: LeaderboardPlayerUnicityDisplayOption,
|
||||
visibility?: LeaderboardVisibilityOption,
|
||||
|}) => {
|
||||
if (!currentLeaderboardId) return;
|
||||
if (attributes.sort) dispatch({ type: 'PURGE_NAVIGATION' }); // When changing playerUnicityDisplayChoice, it will change the displayOnlyBestEntry state variable, which will purge navigation.
|
||||
const updatedLeaderboard = await doUpdateLeaderboard(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
currentLeaderboardId,
|
||||
attributes
|
||||
);
|
||||
if (!updatedLeaderboard) return;
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_OR_CREATE_LEADERBOARD',
|
||||
payload: updatedLeaderboard,
|
||||
});
|
||||
|
||||
if (attributes.sort) await fetchEntries();
|
||||
};
|
||||
|
||||
const resetLeaderboard = async () => {
|
||||
if (!currentLeaderboardId) return;
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
const updatedLeaderboard = await doResetLeaderboard(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
currentLeaderboardId
|
||||
);
|
||||
if (!updatedLeaderboard) return;
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_OR_CREATE_LEADERBOARD',
|
||||
payload: updatedLeaderboard,
|
||||
});
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const deleteLeaderboard = async () => {
|
||||
if (!currentLeaderboardId || !leaderboardsByIds) return;
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
await doDeleteLeaderboard(authenticatedUser, gameId, currentLeaderboardId);
|
||||
dispatch({ type: 'REMOVE_LEADERBOARD', payload: currentLeaderboardId });
|
||||
};
|
||||
|
||||
const deleteLeaderboardEntry = async (entryId: string) => {
|
||||
if (!currentLeaderboardId) return;
|
||||
await doDeleteLeaderboardEntry(
|
||||
authenticatedUser,
|
||||
gameId,
|
||||
currentLeaderboardId,
|
||||
entryId
|
||||
);
|
||||
fetchEntries({ uri: pageIndex > 0 ? mapPageIndexToUri[pageIndex] : null });
|
||||
};
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
const navigateToNextPage = React.useCallback(
|
||||
async () => {
|
||||
const nextPageUri = mapPageIndexToUri[pageIndex + 1];
|
||||
if (!nextPageUri) return;
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: pageIndex + 1 });
|
||||
await fetchEntries({ uri: nextPageUri });
|
||||
},
|
||||
[fetchEntries, mapPageIndexToUri, pageIndex]
|
||||
);
|
||||
|
||||
const navigateToPreviousPage = React.useCallback(
|
||||
async () => {
|
||||
if (pageIndex === 1) {
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: 0 });
|
||||
await fetchEntries();
|
||||
} else {
|
||||
const previousPageUri = mapPageIndexToUri[pageIndex - 1];
|
||||
if (!previousPageUri) return;
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: pageIndex - 1 });
|
||||
await fetchEntries({ uri: previousPageUri });
|
||||
}
|
||||
},
|
||||
[fetchEntries, mapPageIndexToUri, pageIndex]
|
||||
);
|
||||
|
||||
const navigateToFirstPage = React.useCallback(
|
||||
async () => {
|
||||
dispatch({ type: 'SET_PAGE_INDEX', payload: 0 });
|
||||
await fetchEntries();
|
||||
},
|
||||
[fetchEntries]
|
||||
);
|
||||
|
||||
// --- Effects ---
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
dispatch({ type: 'SET_LEADERBOARDS', payload: null });
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
},
|
||||
[gameId]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!currentLeaderboardId) return;
|
||||
dispatch({ type: 'PURGE_NAVIGATION' });
|
||||
fetchEntries();
|
||||
},
|
||||
[currentLeaderboardId, displayOnlyBestEntry, fetchEntries]
|
||||
);
|
||||
|
||||
return (
|
||||
<LeaderboardContext.Provider
|
||||
value={{
|
||||
leaderboards: !!leaderboardsByIds
|
||||
? // $FlowFixMe
|
||||
Object.values(leaderboardsByIds)
|
||||
: null,
|
||||
currentLeaderboard,
|
||||
displayOnlyBestEntry,
|
||||
browsing: {
|
||||
entries,
|
||||
goToNextPage: !!mapPageIndexToUri[pageIndex + 1]
|
||||
? navigateToNextPage
|
||||
: null,
|
||||
goToPreviousPage:
|
||||
pageIndex === 1 || !!mapPageIndexToUri[pageIndex - 1]
|
||||
? navigateToPreviousPage
|
||||
: null,
|
||||
goToFirstPage: pageIndex === 0 ? null : navigateToFirstPage,
|
||||
},
|
||||
setDisplayOnlyBestEntry,
|
||||
createLeaderboard,
|
||||
listLeaderboards,
|
||||
selectLeaderboard,
|
||||
updateLeaderboard,
|
||||
resetLeaderboard,
|
||||
deleteLeaderboard,
|
||||
deleteLeaderboardEntry,
|
||||
fetchLeaderboardEntries: fetchEntries,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LeaderboardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LeaderboardProvider;
|
@@ -71,6 +71,7 @@ export const create = (authentication: Authentication) => {
|
||||
renderExportDialog={props => (
|
||||
<ExportDialog
|
||||
project={props.project}
|
||||
onSaveProject={props.onSaveProject}
|
||||
onChangeSubscription={props.onChangeSubscription}
|
||||
onClose={props.onClose}
|
||||
automatedExporters={localAutomatedExporters}
|
||||
|
@@ -47,6 +47,7 @@ export function DraggableClosableTabs({
|
||||
<DraggableClosableTab
|
||||
index={id}
|
||||
label={editorTab.label}
|
||||
icon={editorTab.icon}
|
||||
key={editorTab.key}
|
||||
id={`tab-${editorTab.key.replace(/\s/g, '-')}`}
|
||||
active={isCurrentTab}
|
||||
@@ -88,6 +89,7 @@ export function DraggableClosableTab({
|
||||
onCloseOthers,
|
||||
onCloseAll,
|
||||
label,
|
||||
icon,
|
||||
closable,
|
||||
onClick,
|
||||
onActivated,
|
||||
@@ -129,6 +131,7 @@ export function DraggableClosableTab({
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseAll={onCloseAll}
|
||||
label={label}
|
||||
icon={icon}
|
||||
closable={closable}
|
||||
onClick={onClick}
|
||||
onActivated={onActivated}
|
||||
|
@@ -29,7 +29,8 @@ export type EditorTab = {|
|
||||
// A reference to the editor.
|
||||
editorRef: ?EditorRef,
|
||||
// The label shown on the tab.
|
||||
label: string,
|
||||
label?: string,
|
||||
icon?: React.Node,
|
||||
// The name of the layout/external layout/external events/extension.
|
||||
projectItemName: ?string,
|
||||
// A unique key for the tab.
|
||||
@@ -56,6 +57,7 @@ export const openEditorTab = (
|
||||
state: EditorTabsState,
|
||||
{
|
||||
label,
|
||||
icon,
|
||||
projectItemName,
|
||||
renderEditorContainer,
|
||||
key,
|
||||
@@ -63,7 +65,8 @@ export const openEditorTab = (
|
||||
dontFocusTab,
|
||||
closable,
|
||||
}: {|
|
||||
label: string,
|
||||
label?: string,
|
||||
icon?: React.Node,
|
||||
projectItemName: ?string,
|
||||
renderEditorContainer: (
|
||||
props: RenderEditorContainerPropsWithRef
|
||||
@@ -87,6 +90,7 @@ export const openEditorTab = (
|
||||
|
||||
const editorTab: EditorTab = {
|
||||
label,
|
||||
icon,
|
||||
projectItemName,
|
||||
renderEditorContainer,
|
||||
key,
|
||||
|
@@ -14,6 +14,7 @@ export type AlertMessageIdentifier =
|
||||
| 'automatic-lighting-layer'
|
||||
| 'object-moved-in-lighting-layer'
|
||||
| 'use-non-smoothed-textures'
|
||||
| 'use-pixel-rounding'
|
||||
| 'use-nearest-scale-mode'
|
||||
| 'maximum-fps-too-low'
|
||||
| 'minimum-fps-too-low'
|
||||
@@ -53,6 +54,10 @@ export const allAlertMessages: Array<{
|
||||
key: 'use-non-smoothed-textures',
|
||||
label: <Trans>Using non smoothed textures</Trans>,
|
||||
},
|
||||
{
|
||||
key: 'use-pixel-rounding',
|
||||
label: <Trans>Using pixel rounding</Trans>,
|
||||
},
|
||||
{
|
||||
key: 'use-nearest-scale-mode',
|
||||
label: <Trans>Using Nearest Scale Mode</Trans>,
|
||||
@@ -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);
|
||||
|
@@ -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={
|
||||
|
@@ -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 => ({
|
||||
|
@@ -5,6 +5,7 @@ import * as React from 'react';
|
||||
import './MainFrame.css';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import Toolbar, { type ToolbarInterface } from './Toolbar';
|
||||
import ProjectTitlebar from './ProjectTitlebar';
|
||||
import PreferencesDialog from './Preferences/PreferencesDialog';
|
||||
@@ -132,6 +133,7 @@ import {
|
||||
} from '../Utils/GDevelopServices/Badge';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import OnboardingDialog from './Onboarding/OnboardingDialog';
|
||||
import LeaderboardProvider from '../Leaderboard/LeaderboardProvider';
|
||||
|
||||
const GD_STARTUP_TIMES = global.GD_STARTUP_TIMES || [];
|
||||
|
||||
@@ -1377,7 +1379,7 @@ const MainFrame = (props: Props) => {
|
||||
setState(state => ({
|
||||
...state,
|
||||
editorTabs: openEditorTab(state.editorTabs, {
|
||||
label: i18n._(t`Home`),
|
||||
icon: <HomeIcon role="img" titleAccess="Home" />,
|
||||
projectItemName: null,
|
||||
renderEditorContainer: renderHomePageContainer,
|
||||
key: 'start page',
|
||||
@@ -1385,7 +1387,7 @@ const MainFrame = (props: Props) => {
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[i18n, setState]
|
||||
[setState]
|
||||
);
|
||||
|
||||
const _openDebugger = React.useCallback(
|
||||
@@ -2162,86 +2164,92 @@ const MainFrame = (props: Props) => {
|
||||
onTabActived={(editorTab: EditorTab) => _onEditorTabActived(editorTab)}
|
||||
onDropTab={onDropEditorTab}
|
||||
/>
|
||||
{getEditors(state.editorTabs).map((editorTab, id) => {
|
||||
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
|
||||
return (
|
||||
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
|
||||
<CommandsContextScopedProvider active={isCurrentTab}>
|
||||
<ErrorBoundary>
|
||||
{editorTab.renderEditorContainer({
|
||||
isActive: isCurrentTab,
|
||||
extraEditorProps: editorTab.extraEditorProps,
|
||||
project: currentProject,
|
||||
ref: editorRef => (editorTab.editorRef = editorRef),
|
||||
setToolbar: editorToolbar =>
|
||||
setEditorToolbar(editorToolbar, isCurrentTab),
|
||||
onChangeSubscription: () => openSubscriptionDialog(true),
|
||||
projectItemName: editorTab.projectItemName,
|
||||
setPreviewedLayout,
|
||||
onOpenExternalEvents: openExternalEvents,
|
||||
onOpenEvents: (sceneName: string) =>
|
||||
openLayout(sceneName, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
previewDebuggerServer,
|
||||
hotReloadPreviewButtonProps,
|
||||
onOpenLayout: name =>
|
||||
openLayout(name, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
resourceSources: props.resourceSources,
|
||||
onChooseResource,
|
||||
resourceExternalEditors,
|
||||
onCreateEventsFunction,
|
||||
openInstructionOrExpression,
|
||||
unsavedChanges: unsavedChanges,
|
||||
canOpen: !!props.storageProviders.filter(
|
||||
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
|
||||
).length,
|
||||
onOpen: () => chooseProject(),
|
||||
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
|
||||
onCreateFromExampleShortHeader: onCreateFromExampleShortHeader,
|
||||
onCreateBlank: onCreateBlank,
|
||||
onOpenProjectAfterCreation: onOpenProjectAfterCreation,
|
||||
onOpenProjectManager: () => openProjectManager(true),
|
||||
onCloseProject: () => askToCloseProject(),
|
||||
onOpenTutorials: () => onOpenTutorials(),
|
||||
onOpenGamesShowcase: () => onOpenGamesShowcase(),
|
||||
onOpenExamples: () => onOpenExamples(),
|
||||
onOpenProfile: () => openProfileDialogWithTab('profile'),
|
||||
onOpenHelpFinder: () => openHelpFinderDialog(true),
|
||||
onOpenLanguageDialog: () => openLanguageDialog(true),
|
||||
onLoadEventsFunctionsExtensions: () =>
|
||||
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
|
||||
currentProject
|
||||
),
|
||||
onDeleteResource: (
|
||||
resource: gdResource,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
// TODO: Project wide refactoring of objects/events using the resource
|
||||
cb(true);
|
||||
},
|
||||
onRenameResource: (
|
||||
resource: gdResource,
|
||||
newName: string,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
if (currentProject)
|
||||
renameResourcesInProject(currentProject, {
|
||||
[resource.getName()]: newName,
|
||||
});
|
||||
<LeaderboardProvider
|
||||
gameId={
|
||||
state.currentProject ? state.currentProject.getProjectUuid() : ''
|
||||
}
|
||||
>
|
||||
{getEditors(state.editorTabs).map((editorTab, id) => {
|
||||
const isCurrentTab = getCurrentTabIndex(state.editorTabs) === id;
|
||||
return (
|
||||
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
|
||||
<CommandsContextScopedProvider active={isCurrentTab}>
|
||||
<ErrorBoundary>
|
||||
{editorTab.renderEditorContainer({
|
||||
isActive: isCurrentTab,
|
||||
extraEditorProps: editorTab.extraEditorProps,
|
||||
project: currentProject,
|
||||
ref: editorRef => (editorTab.editorRef = editorRef),
|
||||
setToolbar: editorToolbar =>
|
||||
setEditorToolbar(editorToolbar, isCurrentTab),
|
||||
onChangeSubscription: () => openSubscriptionDialog(true),
|
||||
projectItemName: editorTab.projectItemName,
|
||||
setPreviewedLayout,
|
||||
onOpenExternalEvents: openExternalEvents,
|
||||
onOpenEvents: (sceneName: string) =>
|
||||
openLayout(sceneName, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
previewDebuggerServer,
|
||||
hotReloadPreviewButtonProps,
|
||||
onOpenLayout: name =>
|
||||
openLayout(name, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: false,
|
||||
}),
|
||||
resourceSources: props.resourceSources,
|
||||
onChooseResource,
|
||||
resourceExternalEditors,
|
||||
onCreateEventsFunction,
|
||||
openInstructionOrExpression,
|
||||
unsavedChanges: unsavedChanges,
|
||||
canOpen: !!props.storageProviders.filter(
|
||||
({ hiddenInOpenDialog }) => !hiddenInOpenDialog
|
||||
).length,
|
||||
onOpen: () => chooseProject(),
|
||||
onOpenRecentFile: openFromFileMetadataWithStorageProvider,
|
||||
onCreateFromExampleShortHeader: onCreateFromExampleShortHeader,
|
||||
onCreateBlank: onCreateBlank,
|
||||
onOpenProjectAfterCreation: onOpenProjectAfterCreation,
|
||||
onOpenProjectManager: () => openProjectManager(true),
|
||||
onCloseProject: () => askToCloseProject(),
|
||||
onOpenTutorials: () => onOpenTutorials(),
|
||||
onOpenGamesShowcase: () => onOpenGamesShowcase(),
|
||||
onOpenExamples: () => onOpenExamples(),
|
||||
onOpenProfile: () => openProfileDialogWithTab('profile'),
|
||||
onOpenHelpFinder: () => openHelpFinderDialog(true),
|
||||
onOpenLanguageDialog: () => openLanguageDialog(true),
|
||||
onLoadEventsFunctionsExtensions: () =>
|
||||
eventsFunctionsExtensionsState.loadProjectEventsFunctionsExtensions(
|
||||
currentProject
|
||||
),
|
||||
onDeleteResource: (
|
||||
resource: gdResource,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
// TODO: Project wide refactoring of objects/events using the resource
|
||||
cb(true);
|
||||
},
|
||||
onRenameResource: (
|
||||
resource: gdResource,
|
||||
newName: string,
|
||||
cb: boolean => void
|
||||
) => {
|
||||
if (currentProject)
|
||||
renameResourcesInProject(currentProject, {
|
||||
[resource.getName()]: newName,
|
||||
});
|
||||
|
||||
cb(true);
|
||||
},
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</CommandsContextScopedProvider>
|
||||
</TabContentContainer>
|
||||
);
|
||||
})}
|
||||
cb(true);
|
||||
},
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</CommandsContextScopedProvider>
|
||||
</TabContentContainer>
|
||||
);
|
||||
})}
|
||||
</LeaderboardProvider>
|
||||
<CommandPalette ref={commandPaletteRef} />
|
||||
<LoaderModal show={showLoader} />
|
||||
<HelpFinder
|
||||
@@ -2266,6 +2274,7 @@ const MainFrame = (props: Props) => {
|
||||
openSubscriptionDialog(true);
|
||||
},
|
||||
project: state.currentProject,
|
||||
onSaveProject: saveProject,
|
||||
})}
|
||||
{!!renderCreateDialog &&
|
||||
state.createDialogOpen &&
|
||||
|
@@ -3,6 +3,7 @@ import { Trans, t } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import OpenInNew from '@material-ui/icons/OpenInNew';
|
||||
import { Column, Line, Spacer } from '../UI/Grid';
|
||||
import { ResponsiveLineStackLayout } from '../UI/Layout';
|
||||
import PlaceholderLoader from '../UI/PlaceholderLoader';
|
||||
@@ -14,8 +15,11 @@ import PlaceholderError from '../UI/PlaceholderError';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import UserAchievements from './Achievement/UserAchievements';
|
||||
import { type Badge } from '../Utils/GDevelopServices/Badge';
|
||||
import Window from '../Utils/Window';
|
||||
import { GDevelopGamesPlatform } from '../Utils/GDevelopServices/ApiConfigs';
|
||||
|
||||
type DisplayedProfile = {
|
||||
id: string,
|
||||
+email?: string,
|
||||
description: ?string,
|
||||
username: ?string,
|
||||
@@ -44,21 +48,41 @@ const ProfileDetails = ({
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Column>
|
||||
<Line alignItems="center">
|
||||
<Avatar src={getGravatarUrl(profile.email || '', { size: 40 })} />
|
||||
<Spacer />
|
||||
<Text
|
||||
size="title"
|
||||
style={{
|
||||
opacity: profile.username ? 1.0 : 0.5,
|
||||
}}
|
||||
>
|
||||
{profile.username ||
|
||||
(isAuthenticatedUserProfile
|
||||
? i18n._(t`Edit your profile to pick a username!`)
|
||||
: i18n._(t`No username`))}
|
||||
</Text>
|
||||
</Line>
|
||||
<ResponsiveLineStackLayout
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
noColumnMargin
|
||||
>
|
||||
<Line>
|
||||
<Avatar src={getGravatarUrl(profile.email || '', { size: 40 })} />
|
||||
<Spacer />
|
||||
<Text
|
||||
size="title"
|
||||
style={{
|
||||
opacity: profile.username ? 1.0 : 0.5,
|
||||
}}
|
||||
>
|
||||
{profile.username ||
|
||||
(isAuthenticatedUserProfile
|
||||
? i18n._(t`Edit your profile to pick a username!`)
|
||||
: i18n._(t`No username`))}
|
||||
</Text>
|
||||
</Line>
|
||||
{profile.id && (
|
||||
<RaisedButton
|
||||
label={i18n._(t`Access public profile`)}
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
GDevelopGamesPlatform.getUserPublicProfileUrl(
|
||||
profile.id,
|
||||
profile.username
|
||||
)
|
||||
)
|
||||
}
|
||||
icon={<OpenInNew />}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveLineStackLayout>
|
||||
{isAuthenticatedUserProfile && profile.email && (
|
||||
<Line>
|
||||
<TextField
|
||||
|
@@ -80,6 +80,7 @@ const ProfileDialog = ({
|
||||
cannotBeDismissed={false}
|
||||
open={open}
|
||||
noMargin
|
||||
fullHeight
|
||||
noTitleMargin
|
||||
title={
|
||||
<Tabs value={currentTab} onChange={_onChangeTab}>
|
||||
@@ -113,7 +114,7 @@ const ProfileDialog = ({
|
||||
{currentTab === 'games-dashboard' &&
|
||||
(authenticatedUser.authenticated ? (
|
||||
<Line>
|
||||
<ColumnStackLayout expand>
|
||||
<ColumnStackLayout expand noOverflowParent>
|
||||
<GamesList project={currentProject} />
|
||||
</ColumnStackLayout>
|
||||
</Line>
|
||||
|
@@ -50,7 +50,7 @@ function InstalledExtensionDetails({
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<ExtensionInstallDialog
|
||||
alreadyInstalled
|
||||
project={project}
|
||||
isInstalling={isInstalling}
|
||||
onClose={onClose}
|
||||
onInstall={() => installOrUpdateExtension(i18n)}
|
||||
|
@@ -36,7 +36,7 @@ import {
|
||||
type HotReloadPreviewButtonProps,
|
||||
NewPreviewIcon,
|
||||
} from '../HotReload/HotReloadPreviewButton';
|
||||
import PublicGameProperties from './PublicGameProperties';
|
||||
import PublicGameProperties from '../GameDashboard/PublicGameProperties';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject,
|
||||
@@ -541,6 +541,17 @@ function ProjectPropertiesDialog(props: Props) {
|
||||
</Trans>
|
||||
</DismissableAlertMessage>
|
||||
)}
|
||||
{pixelsRounding && (
|
||||
<DismissableAlertMessage
|
||||
identifier="use-pixel-rounding"
|
||||
kind="info"
|
||||
>
|
||||
<Trans>
|
||||
To avoid flickering on objects followed by the camera, use
|
||||
sprites with even dimensions.
|
||||
</Trans>
|
||||
</DismissableAlertMessage>
|
||||
)}
|
||||
|
||||
<Text size="title">
|
||||
<Trans>Project files</Trans>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user