Compare commits

..

1 Commits

Author SHA1 Message Date
Florian Rival
7b6f6e4683 Add body type selector actions to physics behaviors 2025-10-01 17:32:09 +02:00
189 changed files with 1619 additions and 7680 deletions

View File

@@ -293,25 +293,6 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.AddCodeOnlyParameter("currentScene", "")
.MarkAsAdvanced();
obj.AddAction(
"RotateTowardObject",
_("Rotate toward another object"),
_("Rotate an object towards another object, with the specified speed. "
"Note that if multiple instances of the target object are picked, "
"only the first one will be used. Use a For Each event or actions "
"like \"Pick nearest object\", \"Pick a random object\" to refine "
"the choice of the target object."),
_("Rotate _PARAM0_ towards _PARAM1_ at speed _PARAM2_ deg/second"),
_("Angle"),
"res/actions/rotate24_black.png",
"res/actions/rotate_black.png")
.AddParameter("object", _("Object"))
.AddParameter("objectPtr", _("Target object"))
.AddParameter("expression", _("Angular speed (in degrees per second)"))
.SetParameterLongDescription(_("Enter 0 for an immediate rotation."))
.AddCodeOnlyParameter("currentScene", "")
.MarkAsAdvanced();
obj.AddAction(
"AddForceXY",
_("Add a force"),
@@ -1636,7 +1617,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
extension
.AddAction("AjoutObjConcern",
_("Pick all object instances"),
_("Pick all instances"),
_("Pick all instances of the specified object(s). When you "
"pick all instances, "
"the next conditions and actions of this event work on all "
@@ -1650,34 +1631,20 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.MarkAsAdvanced();
extension
.AddAction("AjoutHasard",
_("Pick a random object"),
_("Pick one instance from all the specified objects. When an "
"instance is picked, the next conditions and actions of "
"this event work only on that object instance."),
_("Pick a random _PARAM1_"),
_("Objects"),
"res/actions/ajouthasard24.png",
"res/actions/ajouthasard.png")
.AddAction(
"AjoutHasard",
_("Pick a random object"),
_("Pick one object from all the specified objects. When an object "
"is picked, the next conditions and actions of this event work "
"only on that object."),
_("Pick a random _PARAM1_"),
_("Objects"),
"res/actions/ajouthasard24.png",
"res/actions/ajouthasard.png")
.AddCodeOnlyParameter("objectsContext", "")
.AddParameter("objectList", _("Object"))
.MarkAsSimple();
extension
.AddAction(
"PickNearest",
_("Pick nearest object"),
_("Pick the instance of this object that is nearest to the specified "
"position."),
_("Pick the _PARAM0_ that is nearest to _PARAM1_;_PARAM2_"),
_("Objects"),
"res/conditions/distance24.png",
"res/conditions/distance.png")
.AddParameter("objectList", _("Object"))
.AddParameter("expression", _("X position"))
.AddParameter("expression", _("Y position"))
.MarkAsSimple();
extension
.AddAction(
"MoveObjects",
@@ -1727,12 +1694,11 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
extension
.AddCondition(
"AjoutObjConcern",
_("Pick all object instances"),
_("Pick all instances of the specified object(s). When you "
"pick all instances, "
_("Pick all objects"),
_("Pick all the specified objects. When you pick all objects, "
"the next conditions and actions of this event work on all "
"of them."),
_("Pick all instances of _PARAM1_"),
_("Pick all _PARAM1_ objects"),
_("Objects"),
"res/conditions/add24.png",
"res/conditions/add.png")
@@ -1741,15 +1707,16 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.MarkAsAdvanced();
extension
.AddCondition("AjoutHasard",
_("Pick a random object"),
_("Pick one instance from all the specified objects. When "
"an instance is picked, the next conditions and actions "
"of this event work only on that object instance."),
_("Pick a random _PARAM1_"),
_("Objects"),
"res/conditions/ajouthasard24.png",
"res/conditions/ajouthasard.png")
.AddCondition(
"AjoutHasard",
_("Pick a random object"),
_("Pick one object from all the specified objects. When an object "
"is picked, the next conditions and actions of this event work "
"only on that object."),
_("Pick a random _PARAM1_"),
_("Objects"),
"res/conditions/ajouthasard24.png",
"res/conditions/ajouthasard.png")
.AddCodeOnlyParameter("objectsContext", "")
.AddParameter("objectList", _("Object"))
.MarkAsSimple();
@@ -1758,9 +1725,9 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.AddCondition(
"PickNearest",
_("Pick nearest object"),
_("Pick the instance of this object that is nearest to the specified "
"position. If the condition is inverted, the instance farthest "
"from the specified position is picked instead."),
_("Pick the object of this type that is nearest to the specified "
"position. If the condition is inverted, the object farthest from "
"the specified position is picked instead."),
_("Pick the _PARAM0_ that is nearest to _PARAM1_;_PARAM2_"),
_("Objects"),
"res/conditions/distance24.png",

View File

@@ -37,7 +37,6 @@ void EventsBasedObjectVariant::SerializeTo(SerializerElement &element) const {
layers.SerializeLayersTo(element.AddChild("layers"));
initialInstances.SerializeTo(element.AddChild("instances"));
editorSettings.SerializeTo(element.AddChild("editionSettings"));
}
void EventsBasedObjectVariant::UnserializeFrom(
@@ -67,7 +66,6 @@ void EventsBasedObjectVariant::UnserializeFrom(
layers.Reset();
}
initialInstances.UnserializeFrom(element.GetChild("instances"));
editorSettings.UnserializeFrom(element.GetChild("editionSettings"));
}
} // namespace gd

View File

@@ -5,7 +5,6 @@
*/
#pragma once
#include "GDCore/IDE/Dialogs/LayoutEditorCanvas/EditorSettings.h"
#include "GDCore/Project/InitialInstancesContainer.h"
#include "GDCore/Project/LayersContainer.h"
#include "GDCore/Project/ObjectsContainer.h"
@@ -200,19 +199,6 @@ public:
const gd::String &GetAssetStoreOriginalName() const {
return assetStoreOriginalName;
};
/**
*
* \brief Get the user settings for the IDE.
*/
const gd::EditorSettings& GetAssociatedEditorSettings() const {
return editorSettings;
}
/**
* \brief Get the user settings for the IDE.
*/
gd::EditorSettings& GetAssociatedEditorSettings() { return editorSettings; }
void SerializeTo(SerializerElement &element) const;
@@ -238,7 +224,6 @@ private:
* store.
*/
gd::String assetStoreOriginalName;
gd::EditorSettings editorSettings;
};
} // namespace gd

View File

@@ -24,7 +24,7 @@ The rest of this page is an introduction to the main concepts of GDevelop archit
Extensions do have the same distinction between the "**IDE**" part and the "**Runtime**" part. For example, most extensions have:
- A file called [`JsExtension.js`](https://github.com/4ian/GDevelop/blob/master/Extensions/ExampleJsExtension/JsExtension.js), which contains the _declaration_ of the extension for the **IDE**
- A file called [`JsExtension.js`(https://github.com/4ian/GDevelop/blob/master/Extensions/ExampleJsExtension/JsExtension.js)], which contains the _declaration_ of the extension for the **IDE**
- One or more files implementing the feature for the game, in other words for **Runtime**. This can be a [Runtime Object](https://github.com/4ian/GDevelop/blob/master/Extensions/ExampleJsExtension/dummyruntimeobject.ts) or a [Runtime Behavior](https://github.com/4ian/GDevelop/blob/master/Extensions/ExampleJsExtension/dummyruntimebehavior.ts), [functions called by actions or conditions](https://github.com/4ian/GDevelop/blob/master/Extensions/ExampleJsExtension/examplejsextensiontools.ts) or by the game engine.
### "Runtime" and "IDE" difference using an example: the `gd::Variable` class

View File

@@ -97,14 +97,6 @@ namespace gdjs {
oldObjectData: Object3DData,
newObjectData: Object3DData
): boolean {
this.updateOriginalDimensionsFromObjectData(oldObjectData, newObjectData);
return true;
}
updateOriginalDimensionsFromObjectData(
oldObjectData: Object3DData,
newObjectData: Object3DData
): void {
// There is no need to check if they changed because events can't modify them.
this._setOriginalWidth(
getValidDimensionValue(newObjectData.content.width)
@@ -115,6 +107,7 @@ namespace gdjs {
this._setOriginalDepth(
getValidDimensionValue(newObjectData.content.depth)
);
return true;
}
getNetworkSyncData(

View File

@@ -1901,11 +1901,6 @@ module.exports = {
.getOrCreate('density')
.setValue('0.0012')
.setLabel(_('Density'))
.setDescription(
_(
'Density of the fog. Usual values are between 0.0005 (far away) and 0.005 (very thick fog).'
)
)
.setType('number');
}
{

View File

@@ -153,14 +153,6 @@ namespace gdjs {
}
}
override updateOriginalDimensionsFromObjectData(
oldObjectData: Object3DData,
newObjectData: Object3DData
): void {
// Original dimensions must not be reset by `super.updateFromObjectData`.
// `_updateModel` has a different logic to evaluate them using `keepAspectRatio`.
}
updateFromObjectData(
oldObjectData: Model3DObjectData,
newObjectData: Model3DObjectData
@@ -190,14 +182,8 @@ namespace gdjs {
oldObjectData.content.keepAspectRatio !==
newObjectData.content.keepAspectRatio ||
oldObjectData.content.materialType !==
newObjectData.content.materialType ||
oldObjectData.content.centerLocation !==
newObjectData.content.centerLocation
newObjectData.content.materialType
) {
// The center is applied to the model by `_updateModel`.
this._centerPoint = getPointForLocation(
newObjectData.content.centerLocation
);
this._updateModel(newObjectData);
}
if (
@@ -207,7 +193,14 @@ namespace gdjs {
this._originPoint = getPointForLocation(
newObjectData.content.originLocation
);
this._renderer.updatePosition();
}
if (
oldObjectData.content.centerLocation !==
newObjectData.content.centerLocation
) {
this._centerPoint = getPointForLocation(
newObjectData.content.centerLocation
);
}
if (
oldObjectData.content.isCastingShadow !==
@@ -288,17 +281,14 @@ namespace gdjs {
const rotationX = objectData.content.rotationX || 0;
const rotationY = objectData.content.rotationY || 0;
const rotationZ = objectData.content.rotationZ || 0;
const width = objectData.content.width || 100;
const height = objectData.content.height || 100;
const depth = objectData.content.depth || 100;
const keepAspectRatio = objectData.content.keepAspectRatio;
this._renderer._updateModel(
rotationX,
rotationY,
rotationZ,
width,
height,
depth,
this._getOriginalWidth(),
this._getOriginalHeight(),
this._getOriginalDepth(),
keepAspectRatio
);
}

View File

@@ -233,10 +233,6 @@ namespace gdjs {
this._object._setOriginalWidth(scaleRatio * modelWidth);
this._object._setOriginalHeight(scaleRatio * modelHeight);
this._object._setOriginalDepth(scaleRatio * modelDepth);
} else {
this._object._setOriginalWidth(originalWidth);
this._object._setOriginalHeight(originalHeight);
this._object._setOriginalDepth(originalDepth);
}
}
@@ -290,7 +286,6 @@ namespace gdjs {
this.get3DRendererObject().remove(this._threeObject);
this.get3DRendererObject().add(threeObject);
this._threeObject = threeObject;
this.updatePosition();
this._updateShadow();
// Start the current animation on the new 3D object.

View File

@@ -21,9 +21,7 @@ module.exports = {
.setExtensionInformation(
'DebuggerTools',
_('Debugger Tools'),
_(
'Allow to interact with the editor debugger from the game (notably: enable 2D debug draw, log a message in the debugger console).'
),
_('Allow to interact with the editor debugger from the game.'),
'Arthur Pacaud (arthuro555), Aurélien Vivet (Bouh)',
'MIT'
)

View File

@@ -12,8 +12,7 @@ This project is released under the MIT License.
#include "GDCore/Tools/Localization.h"
void DestroyOutsideBehavior::InitializeContent(gd::SerializerElement& content) {
content.SetAttribute("extraBorder", 200);
content.SetAttribute("unseenGraceDistance", 10000);
content.SetAttribute("extraBorder", 300);
}
#if defined(GD_IDE_ONLY)
@@ -28,15 +27,7 @@ DestroyOutsideBehavior::GetProperties(
.SetType("Number")
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetLabel(_("Deletion margin"))
.SetDescription(_("Margin before deleting the object, in pixels."));
properties["unseenGraceDistance"]
.SetValue(gd::String::From(
behaviorContent.GetDoubleAttribute("unseenGraceDistance", 0)))
.SetType("Number")
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetLabel(_("Unseen object grace distance"))
.SetDescription(_("If the object hasn't been visible yet, don't delete it until it travels this far beyond the screen (in pixels). Useful to avoid objects being deleted before they are visible when they spawn."));
.SetDescription(_("Margin before deleting the object, in pixels"));
return properties;
}
@@ -47,8 +38,6 @@ bool DestroyOutsideBehavior::UpdateProperty(
const gd::String& value) {
if (name == "extraBorder")
behaviorContent.SetAttribute("extraBorder", value.To<double>());
else if (name == "unseenGraceDistance")
behaviorContent.SetAttribute("unseenGraceDistance", value.To<double>());
else
return false;

View File

@@ -6,7 +6,6 @@ This project is released under the MIT License.
*/
#include "DestroyOutsideBehavior.h"
#include "GDCore/Extensions/Metadata/MultipleInstructionMetadata.h"
#include "GDCore/Extensions/PlatformExtension.h"
#include "GDCore/Project/BehaviorsSharedData.h"
#include "GDCore/Tools/Localization.h"
@@ -20,10 +19,11 @@ void DeclareDestroyOutsideBehaviorExtension(gd::PlatformExtension& extension) {
"outside of the bounds of the 2D camera. Useful for 2D bullets or "
"other short-lived objects. Don't use it for 3D objects in a "
"FPS/TPS game or any game with a camera not being a top view "
"(for 3D objects, prefer comparing the position, for example Z "
"position to see if an object goes outside of the bound of the "
"map). If the object appears outside of the screen, it's not "
"removed unless it goes beyond the unseen object grace distance."),
"(for 3D objects, prefer comparing "
"the position, for example Z position to see if an object goes "
"outside of the bound of the map). Be careful when using this "
"behavior because if the object appears outside of the screen, it "
"will be immediately removed."),
"Florian Rival",
"Open source (MIT License)")
.SetCategory("Game mechanic")
@@ -44,39 +44,34 @@ void DeclareDestroyOutsideBehaviorExtension(gd::PlatformExtension& extension) {
std::shared_ptr<gd::BehaviorsSharedData>())
.SetQuickCustomizationVisibility(gd::QuickCustomization::Hidden);
aut.AddExpressionAndConditionAndAction(
"number",
"ExtraBorder",
_("Additional border (extra distance before deletion)"),
_("the extra distance (in pixels) the object must "
"travel beyond the screen before it gets deleted"),
_("the additional border"),
_("Destroy outside configuration"),
"CppPlatform/Extensions/destroyoutsideicon24.png")
aut.AddCondition("ExtraBorder",
_("Additional border (extra distance before deletion)"),
_("Compare the extra distance (in pixels) the object must "
"travel beyond the screen before it gets deleted."),
_("the additional border"),
_("Destroy outside configuration"),
"CppPlatform/Extensions/destroyoutsideicon24.png",
"CppPlatform/Extensions/destroyoutsideicon16.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "DestroyOutside")
.UseStandardParameters("number", gd::ParameterOptions::MakeNewOptions())
.MarkAsAdvanced();
.UseStandardRelationalOperatorParameters(
"number", gd::ParameterOptions::MakeNewOptions())
.MarkAsAdvanced()
.SetFunctionName("GetExtraBorder");
// Deprecated:
aut.AddDuplicatedAction("ExtraBorder", "DestroyOutside::SetExtraBorder")
.SetHidden();
aut.AddDuplicatedCondition("ExtraBorder", "DestroyOutside::ExtraBorder")
.SetHidden();
aut.AddExpressionAndConditionAndAction(
"number",
"UnseenGraceDistance",
_("Unseen object grace distance"),
_("the grace distance (in pixels) before deleting the object if it "
"has "
"never been visible on the screen. Useful to avoid objects being "
"deleted before they are visible when they spawn"),
_("the unseen grace distance"),
_("Destroy outside configuration"),
"CppPlatform/Extensions/destroyoutsideicon24.png")
aut.AddAction("ExtraBorder",
_("Additional border (extra distance before deletion)"),
_("Change the extra distance (in pixels) the object must "
"travel beyond the screen before it gets deleted."),
_("the additional border"),
_("Destroy outside configuration"),
"CppPlatform/Extensions/destroyoutsideicon24.png",
"CppPlatform/Extensions/destroyoutsideicon16.png")
.AddParameter("object", _("Object"))
.AddParameter("behavior", _("Behavior"), "DestroyOutside")
.UseStandardParameters("number", gd::ParameterOptions::MakeNewOptions())
.MarkAsAdvanced();
.UseStandardOperatorParameters("number",
gd::ParameterOptions::MakeNewOptions())
.MarkAsAdvanced()
.SetFunctionName("SetExtraBorder")
.SetGetter("GetExtraBorder");
}

View File

@@ -5,11 +5,11 @@ Copyright (c) 2014-2016 Florian Rival (Florian.Rival@gmail.com)
This project is released under the MIT License.
*/
#if defined(GD_IDE_ONLY)
#include <iostream>
#include "GDCore/Extensions/PlatformExtension.h"
#include "GDCore/Tools/Localization.h"
#include <iostream>
void DeclareDestroyOutsideBehaviorExtension(gd::PlatformExtension& extension);
/**
@@ -29,36 +29,19 @@ class DestroyOutsideBehaviorJsExtension : public gd::PlatformExtension {
"Extensions/DestroyOutsideBehavior/"
"destroyoutsideruntimebehavior.js");
GetAllExpressionsForBehavior(
"DestroyOutsideBehavior::DestroyOutside")["ExtraBorder"]
.SetFunctionName("getExtraBorder");
GetAllConditionsForBehavior("DestroyOutsideBehavior::DestroyOutside")
["DestroyOutsideBehavior::DestroyOutside::ExtraBorder"]
.SetFunctionName("getExtraBorder");
GetAllActionsForBehavior("DestroyOutsideBehavior::DestroyOutside")
["DestroyOutsideBehavior::DestroyOutside::SetExtraBorder"]
.SetFunctionName("setExtraBorder")
.SetGetter("getExtraBorder");
// Deprecated:
GetAllConditionsForBehavior("DestroyOutsideBehavior::DestroyOutside")
["DestroyOutsideBehavior::ExtraBorder"]
.SetFunctionName("getExtraBorder");
.SetFunctionName("getExtraBorder")
.SetIncludeFile(
"Extensions/DestroyOutsideBehavior/"
"destroyoutsideruntimebehavior.js");
GetAllActionsForBehavior("DestroyOutsideBehavior::DestroyOutside")
["DestroyOutsideBehavior::ExtraBorder"]
.SetFunctionName("setExtraBorder")
.SetGetter("getExtraBorder");
GetAllExpressionsForBehavior(
"DestroyOutsideBehavior::DestroyOutside")["UnseenGraceDistance"]
.SetFunctionName("getUnseenGraceDistance");
GetAllConditionsForBehavior("DestroyOutsideBehavior::DestroyOutside")
["DestroyOutsideBehavior::DestroyOutside::UnseenGraceDistance"]
.SetFunctionName("getUnseenGraceDistance");
GetAllActionsForBehavior("DestroyOutsideBehavior::DestroyOutside")
["DestroyOutsideBehavior::DestroyOutside::SetUnseenGraceDistance"]
.SetFunctionName("setUnseenGraceDistance")
.SetGetter("getUnseenGraceDistance");
.SetGetter("getExtraBorder")
.SetIncludeFile(
"Extensions/DestroyOutsideBehavior/"
"destroyoutsideruntimebehavior.js");
GD_COMPLETE_EXTENSION_COMPILATION_INFORMATION();
};

View File

@@ -8,9 +8,7 @@ namespace gdjs {
* The DestroyOutsideRuntimeBehavior represents a behavior that destroys the object when it leaves the screen.
*/
export class DestroyOutsideRuntimeBehavior extends gdjs.RuntimeBehavior {
_extraBorder: float;
_unseenGraceDistance: float;
_hasBeenOnScreen: boolean;
_extraBorder: any;
constructor(
instanceContainer: gdjs.RuntimeInstanceContainer,
@@ -19,20 +17,12 @@ namespace gdjs {
) {
super(instanceContainer, behaviorData, owner);
this._extraBorder = behaviorData.extraBorder || 0;
this._unseenGraceDistance = behaviorData.unseenGraceDistance || 0;
this._hasBeenOnScreen = false;
}
updateFromBehaviorData(oldBehaviorData, newBehaviorData): boolean {
if (oldBehaviorData.extraBorder !== newBehaviorData.extraBorder) {
this._extraBorder = newBehaviorData.extraBorder;
}
if (
oldBehaviorData.unseenGraceDistance !==
newBehaviorData.unseenGraceDistance
) {
this._unseenGraceDistance = newBehaviorData.unseenGraceDistance;
}
return true;
}
@@ -45,47 +35,23 @@ namespace gdjs {
const ocy = this.owner.getDrawableY() + this.owner.getCenterY();
const layer = instanceContainer.getLayer(this.owner.getLayer());
const boundingCircleRadius = Math.sqrt(ow * ow + oh * oh) / 2.0;
const cameraLeft = layer.getCameraX() - layer.getCameraWidth() / 2;
const cameraRight = layer.getCameraX() + layer.getCameraWidth() / 2;
const cameraTop = layer.getCameraY() - layer.getCameraHeight() / 2;
const cameraBottom = layer.getCameraY() + layer.getCameraHeight() / 2;
if (
ocx + boundingCircleRadius + this._extraBorder < cameraLeft ||
ocx - boundingCircleRadius - this._extraBorder > cameraRight ||
ocy + boundingCircleRadius + this._extraBorder < cameraTop ||
ocy - boundingCircleRadius - this._extraBorder > cameraBottom
ocx + boundingCircleRadius + this._extraBorder <
layer.getCameraX() - layer.getCameraWidth() / 2 ||
ocx - boundingCircleRadius - this._extraBorder >
layer.getCameraX() + layer.getCameraWidth() / 2 ||
ocy + boundingCircleRadius + this._extraBorder <
layer.getCameraY() - layer.getCameraHeight() / 2 ||
ocy - boundingCircleRadius - this._extraBorder >
layer.getCameraY() + layer.getCameraHeight() / 2
) {
if (this._hasBeenOnScreen) {
// Object is outside the camera area and object was previously seen inside it:
// delete it now.
this.owner.deleteFromScene();
} else if (
ocx + boundingCircleRadius + this._unseenGraceDistance < cameraLeft ||
ocx - boundingCircleRadius - this._unseenGraceDistance >
cameraRight ||
ocy + boundingCircleRadius + this._unseenGraceDistance < cameraTop ||
ocy - boundingCircleRadius - this._unseenGraceDistance > cameraBottom
) {
// Object is outside the camera area and also outside the grace distance:
// force deletion.
this.owner.deleteFromScene();
} else {
// Object is outside the camera area but inside the grace distance
// and was never seen inside the camera area: don't delete it yet.
}
} else {
this._hasBeenOnScreen = true;
//We are outside the camera area.
this.owner.deleteFromScene();
}
}
/**
* Set the additional border outside the camera area.
*
* If the object goes beyond the camera area and this border, it will be deleted (unless it was
* never seen inside the camera area and this border before, in which case it will be deleted
* according to the grace distance).
* Set an additional border to the camera viewport as a buffer before the object gets destroyed.
* @param val Border in pixels.
*/
setExtraBorder(val: number): void {
@@ -93,36 +59,12 @@ namespace gdjs {
}
/**
* Get the additional border outside the camera area.
* Get the additional border of the camera viewport buffer which triggers the destruction of an object.
* @return The additional border around the camera viewport in pixels
*/
getExtraBorder(): number {
return this._extraBorder;
}
/**
* Change the grace distance before an object is deleted if it's outside the camera area
* and was never seen inside the camera area. Typically useful to avoid objects being deleted
* before they are visible when they spawn.
*/
setUnseenGraceDistance(val: number): void {
this._unseenGraceDistance = val;
}
/**
* Get the grace distance before an object is deleted if it's outside the camera area
* and was never seen inside the camera area.
*/
getUnseenGraceDistance(): number {
return this._unseenGraceDistance;
}
/**
* Check if this object has been visible on screen (precisely: inside the camera area *including* the extra border).
*/
hasBeenOnScreen(): boolean {
return this._hasBeenOnScreen;
}
}
gdjs.registerBehavior(
'DestroyOutsideBehavior::DestroyOutside',

View File

@@ -33,49 +33,6 @@ namespace gdjs {
claimSecret?: string;
};
// Rolling window rate limiting
// Implements rate limiting to prevent abuse:
// - Maximum 12 successful successful entries per minute across all leaderboards
// - Maximum 6 successful successful entries per minute per individual leaderboard
// - Works in addition to existing 500ms cooldown between entry tentatives
let _successfulEntriesGlobal: number[] = []; // Timestamps of successful entries across all leaderboards
const GLOBAL_RATE_LIMIT_COUNT = 12;
const PER_LEADERBOARD_RATE_LIMIT_COUNT = 6;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute in milliseconds
/**
* Clean old entries from the rolling window (older than 1 minute)
*/
const cleanOldEntries = (
entries: number[],
currentTime: number
): number[] => {
return entries.filter(
(timestamp) => currentTime - timestamp < RATE_LIMIT_WINDOW_MS
);
};
/**
* Check if adding a new entry would exceed global rate limits.
*/
const wouldExceedGlobalSuccessRateLimit = (): boolean => {
const currentTime = Date.now();
_successfulEntriesGlobal = cleanOldEntries(
_successfulEntriesGlobal,
currentTime
);
return _successfulEntriesGlobal.length >= GLOBAL_RATE_LIMIT_COUNT;
};
/**
* Record a successful entry for global rate limiting tracking.
*/
const recordGlobalSuccessfulEntry = (): void => {
const currentTime = Date.now();
_successfulEntriesGlobal.push(currentTime);
};
/**
* Hold the state of the save of a score for a leaderboard.
*/
@@ -86,9 +43,6 @@ namespace gdjs {
/** The promise that will be resolved when the score saving is done (successfully or not). */
lastSavingPromise: Promise<void> | null = null;
/** Timestamps of successful entries for this leaderboard (for rate limiting) */
private _successfulEntries: number[] = [];
// Score that is being saved:
private _currentlySavingScore: number | null = null;
private _currentlySavingPlayerName: string | null = null;
@@ -153,36 +107,13 @@ namespace gdjs {
);
}
private _wouldExceedPerLeaderboardTentativeRateLimit(): boolean {
// Prevent entries within 500ms of each other (per leaderboard)
// as this would indicate surely a score saved every frame.
//
// Note that is on lastScoreSavingStartedAt, not lastScoreSavingSucceededAt,
// which means we limit tentatives here (and not successes).
private _isTooSoonToSaveAnotherScore(): boolean {
return (
!!this.lastScoreSavingStartedAt &&
Date.now() - this.lastScoreSavingStartedAt < 500
);
}
private _wouldExceedPerLeaderboardSuccessRateLimit(): boolean {
const currentTime = Date.now();
this._successfulEntries = cleanOldEntries(
this._successfulEntries,
currentTime
);
return (
this._successfulEntries.length >= PER_LEADERBOARD_RATE_LIMIT_COUNT
);
}
private _recordPerLeaderboardAndGlobalSuccessfulEntry(): void {
const currentTime = Date.now();
this._successfulEntries.push(currentTime);
recordGlobalSuccessfulEntry();
}
startSaving({
playerName,
playerId,
@@ -210,7 +141,7 @@ namespace gdjs {
throw new Error('Ignoring this saving request.');
}
if (this._wouldExceedPerLeaderboardTentativeRateLimit()) {
if (this._isTooSoonToSaveAnotherScore()) {
logger.warn(
'Last entry was sent too little time ago. Ignoring this one.'
);
@@ -223,24 +154,6 @@ namespace gdjs {
throw new Error('Ignoring this saving request.');
}
// Rolling window rate limiting check for successful entries.
if (wouldExceedGlobalSuccessRateLimit()) {
logger.warn(
'Rate limit exceeded. Too many entries have been successfully sent recently across all leaderboards. Ignoring this one.'
);
this._setError('GLOBAL_RATE_LIMIT_EXCEEDED');
throw new Error('Ignoring this saving request.');
}
if (this._wouldExceedPerLeaderboardSuccessRateLimit()) {
logger.warn(
'Rate limit exceeded. Too many entries have been successfully sent recently for this leaderboard. Ignoring this one.'
);
this._setError('LEADERBOARD_RATE_LIMIT_EXCEEDED');
throw new Error('Ignoring this saving request.');
}
let resolveSavingPromise: () => void;
const savingPromise = new Promise<void>((resolve) => {
resolveSavingPromise = resolve;
@@ -256,9 +169,6 @@ namespace gdjs {
return {
closeSaving: (leaderboardEntry) => {
// Record successful entry for rolling window rate limiting.
this._recordPerLeaderboardAndGlobalSuccessfulEntry();
if (savingPromise !== this.lastSavingPromise) {
logger.info(
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
@@ -486,10 +396,7 @@ namespace gdjs {
try {
const { closeSaving, closeSavingWithError } =
scoreSavingState.startSaving({
playerName,
score,
});
scoreSavingState.startSaving({ playerName, score });
try {
const leaderboardEntry = await saveScore({
@@ -533,10 +440,7 @@ namespace gdjs {
try {
const { closeSaving, closeSavingWithError } =
scoreSavingState.startSaving({
playerId,
score,
});
scoreSavingState.startSaving({ playerId, score });
try {
const leaderboardEntryId = await saveScore({

View File

@@ -794,6 +794,27 @@ module.exports = {
.getCodeExtraInformation()
.setFunctionName('setKinematic');
aut
.addAction(
'SetBodyType',
_('Set body type'),
_('Set the body type of an object.'),
_('Set the body type of _PARAM0_ to _PARAM2_'),
_('Dynamics'),
'res/physics32.png',
'res/physics32.png'
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter(
'stringWithSelector',
_('Body type'),
'["Static", "Dynamic", "Kinematic"]',
false
)
.getCodeExtraInformation()
.setFunctionName('setBodyType');
aut
.addCondition(
'IsBullet',

View File

@@ -1139,6 +1139,21 @@ namespace gdjs {
return this.bodyType === 'Kinematic';
}
setBodyType(bodyType: string): void {
switch (bodyType) {
case 'Static':
this.setStatic();
break;
case 'Kinematic':
this.setKinematic();
break;
case 'Dynamic':
default:
this.setDynamic();
break;
}
}
setKinematic(): void {
// Check if there is no modification
if (this.bodyType === 'Kinematic') {

View File

@@ -821,6 +821,27 @@ module.exports = {
.getCodeExtraInformation()
.setFunctionName('isKinematic');
aut
.addScopedAction(
'SetBodyType',
_('Set body type'),
_('Set the body type of an object.'),
_('Set the body type of _PARAM0_ to _PARAM2_'),
_('Dynamics'),
'JsPlatform/Extensions/physics3d.svg',
'JsPlatform/Extensions/physics3d.svg'
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.addParameter(
'stringWithSelector',
_('Body type'),
'["Static", "Dynamic", "Kinematic"]',
false
)
.getCodeExtraInformation()
.setFunctionName('setBodyType');
aut
.addScopedCondition(
'IsBullet',

View File

@@ -1153,6 +1153,52 @@ namespace gdjs {
return this.bodyType === 'Kinematic';
}
setBodyType(bodyType: string): void {
if (
bodyType !== 'Static' &&
bodyType !== 'Dynamic' &&
bodyType !== 'Kinematic'
) {
return;
}
if (this.bodyType === bodyType) {
return;
}
this.bodyType = bodyType;
if (this._body === null) {
if (!this._createBody()) return;
}
if (this._body === null) {
return;
}
const body = this._body!;
const bodyInterface = this._sharedData.bodyInterface;
let motionType: Jolt.EMotionType;
switch (this.bodyType) {
case 'Static':
motionType = Jolt.EMotionType_Static;
break;
case 'Kinematic':
motionType = Jolt.EMotionType_Kinematic;
break;
case 'Dynamic':
default:
motionType = Jolt.EMotionType_Dynamic;
break;
}
bodyInterface.SetMotionType(
body.GetID(),
motionType,
Jolt.EActivation_Activate
);
bodyInterface.SetObjectLayer(body.GetID(), this.getBodyLayer());
}
isBullet(): boolean {
return this.bullet;
}

View File

@@ -21,9 +21,7 @@ module.exports = {
.setExtensionInformation(
'SaveState',
_('Save State (experimental)'),
_(
'Allows to save and load the full state of a game, usually on the device storage. A Save State, by default, contains the full state of the game (objects, variables, sounds, music, effects etc.). Using the "Save Configuration" behavior, you can customize which objects should not be saved in a Save State. You can also use the "Change the save configuration of a variable" action to change the save configuration of a variable. Finally, both objects, variables and scene/game data can be given a profile name: in this case, when saving or loading with one or more profile names specified, only the object/variables/data belonging to one of the specified profiles will be saved or loaded.'
),
_('Allows to save and load the full state of a game.'),
'Neyl Mahfouf',
'Open source (MIT License)'
)
@@ -34,135 +32,65 @@ module.exports = {
extension
.addAction(
'CreateGameSaveStateInVariable',
'SaveGameSnapshotToVariable',
_('Save game to a variable'),
_(
'Create a Save State and save it to a variable. This is for advanced usage, prefer to use "Save game to device storage" in most cases.'
),
_('Save game in variable _PARAM1_ (profile(s): _PARAM2_)'),
_('Takes a snapshot of the game and save it to a variable.'),
_('Save the game in variable _PARAM1_'),
_('Save'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('variable', _('Variable to store the save to'), '', false)
.addParameter('string', _('Profile(s) to save'), '', true)
.setDefaultValue('"default"')
.setParameterLongDescription(
_(
'Comma-separated list of profile names that must be saved. Only objects tagged with at least one of these profiles will be saved. If no profile names are specified, all objects will be saved (unless they have a "Save Configuration" behavior set to "Do not save").'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.createGameSaveStateInVariable');
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.saveVariableGameSnapshot');
extension
.addAction(
'CreateGameSaveStateInStorage',
'SaveGameSnapshotToStorage',
_('Save game to device storage'),
_('Create a Save State and save it to device storage.'),
_('Save game to device storage named _PARAM1_ (profile(s): _PARAM2_)'),
_('Takes a snapshot of the game and save it to device storage.'),
_('Save the game to device storage under key _PARAM1_'),
_('Save'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('string', _('Storage key to save to'), '', false)
.addParameter('string', _('Profile(s) to save'), '', true)
.setDefaultValue('"default"')
.setParameterLongDescription(
_(
'Comma-separated list of profile names that must be saved. Only objects tagged with at least one of these profiles will be saved. If no profile names are specified, all objects will be saved (unless they have a "Save Configuration" behavior set to "Do not save").'
)
)
.setDefaultValue('no')
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.createGameSaveStateInStorage');
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.saveStorageGameSnapshot');
extension
.addAction(
'RestoreGameSaveStateFromVariable',
'LoadGameSnapshotFromVariable',
_('Load game from variable'),
_(
'Restore the game from a Save State stored in the specified variable. This is for advanced usage, prefer to use "Load game from device storage" in most cases.'
),
_(
'Load game from variable _PARAM1_ (profile(s): _PARAM2_, stop and restart all the scenes currently played: _PARAM3_)'
),
_('Load game from a variable save snapshot.'),
_('Load the game from variable _PARAM0_'),
_('Load'),
'res/actions/saveUp.svg',
'res/actions/saveUp.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('variable', _('Variable to load the game from'), '', false)
.addParameter('string', _('Profile(s) to load'), '', true)
.setDefaultValue('"default"')
.setParameterLongDescription(
_(
'Comma-separated list of profile names that must be loaded. Only objects tagged with at least one of these profiles will be loaded - others will be left alone. If no profile names are specified, all objects will be loaded (unless they have a "Save Configuration" behavior set to "Do not save").'
)
)
.addParameter(
'yesorno',
_('Stop and restart all the scenes currently played?'),
'',
true
)
.setDefaultValue('no')
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.restoreGameSaveStateFromVariable');
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.loadGameFromVariableSnapshot');
extension
.addAction(
'RestoreGameSaveStateFromStorage',
'LoadGameSnapshotFromStorage',
_('Load game from device storage'),
_('Restore the game from a Save State stored on the device.'),
_(
'Load game from device storage named _PARAM1_ (profile(s): _PARAM2_, stop and restart all the scenes currently played: _PARAM3_)'
),
_('Load game from device storage save snapshot.'),
_('Load the game from device storage under key _PARAM0_.'),
_('Load'),
'res/actions/saveUp.svg',
'res/actions/saveUp.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter(
'string',
_('Storage name to load the game from'),
'',
false
)
.addParameter('string', _('Profile(s) to load'), '', true)
.setDefaultValue('"default"')
.setParameterLongDescription(
_(
'Comma-separated list of profile names that must be loaded. Only objects tagged with at least one of these profiles will be loaded - others will be left alone. If no profile names are specified, all objects will be loaded.'
)
)
.addParameter(
'yesorno',
_('Stop and restart all the scenes currently played?'),
'',
true
)
.setDefaultValue('no')
.addParameter('string', _('Storage key to load the game from'), '', false)
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.restoreGameSaveStateFromStorage');
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.loadGameFromStorageSnapshot');
extension
.addExpressionAndCondition(
@@ -176,12 +104,8 @@ module.exports = {
'',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.getSecondsSinceLastSave')
.setGetter('gdjs.saveState.getSecondsSinceLastSave');
@@ -197,12 +121,8 @@ module.exports = {
'',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.getSecondsSinceLastLoad')
.setGetter('gdjs.saveState.getSecondsSinceLastLoad');
@@ -210,276 +130,58 @@ module.exports = {
.addCondition(
'SaveJustSucceeded',
_('Save just succeeded'),
_('The last save attempt just succeeded.'),
_('Save just succeeded'),
_('the save just succeeded'),
_('the save just succeeded'),
_('Save'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.hasSaveJustSucceeded');
extension
.addCondition(
'SaveJustFailed',
_('Save just failed'),
_('The last save attempt just failed.'),
_('Save just failed'),
_('the save just failed'),
_('the save just failed'),
_('Save'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.hasSaveJustFailed');
extension
.addCondition(
'LoadJustSucceeded',
_('Load just succeeded'),
_('The last load attempt just succeeded.'),
_('Load just succeeded'),
_('the load just succeeded'),
_('the load just succeeded'),
_('Load'),
'res/actions/saveUp.svg',
'res/actions/saveUp.svg'
)
.addCodeOnlyParameter('currentScene', '')
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.hasLoadJustSucceeded');
extension
.addCondition(
'LoadJustFailed',
_('Load just failed'),
_('The last load attempt just failed.'),
_('Load just failed'),
_('the load just failed'),
_('the load just failed'),
_('Load'),
'res/actions/saveUp.svg',
'res/actions/saveUp.svg'
)
.addCodeOnlyParameter('currentScene', '')
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setIncludeFile('Extensions/SaveState/savestatetools.js')
.setFunctionName('gdjs.saveState.hasLoadJustFailed');
extension
.addAction(
'SetVariableSaveConfiguration',
_('Change the save configuration of a variable'),
_(
'Set if a scene or global variable should be saved in the default save state. Also allow to specify one or more profiles in which the variable should be saved.'
),
_(
'Change save configuration of _PARAM1_: save it in the default save states: _PARAM2_ and in profiles: _PARAM3_'
),
_('Advanced configuration'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter(
'variable',
_('Variable for which configuration should be changed'),
'',
false
)
.addParameter('yesorno', _('Persist in default save states'), '', false)
.setDefaultValue('yes')
.addParameter(
'string',
_('Profiles in which the variable should be saved'),
'',
true
)
.setDefaultValue('')
.setParameterLongDescription(
_(
'Comma-separated list of profile names in which the variable will be saved. When a save state is created with one or more profile names specified, the variable will be saved only if it matches one of these profiles.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.setVariableSaveConfiguration');
extension
.addAction(
'SetGameDataSaveConfiguration',
_('Change the save configuration of the global game data'),
_(
'Set if the global game data (audio & global variables) should be saved in the default save state. Also allow to specify one or more profiles in which the global game data should be saved.'
),
_(
'Change save configuration of global game data: save them in the default save states: _PARAM1_ and in profiles: _PARAM2_'
),
_('Advanced configuration'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter('yesorno', _('Persist in default save states'), '', false)
.setDefaultValue('yes')
.addParameter(
'string',
_('Profiles in which the global game data should be saved'),
'',
true
)
.setDefaultValue('')
.setParameterLongDescription(
_(
'Comma-separated list of profile names in which the global game data will be saved. When a save state is created with one or more profile names specified, the global game data will be saved only if it matches one of these profiles.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.setGameDataSaveConfiguration');
extension
.addAction(
'SetSceneDataSaveConfiguration',
_('Change the save configuration of a scene data'),
_(
'Set if the data of the specified scene (scene variables, timers, trigger once, wait actions, layers, etc.) should be saved in the default save state. Also allow to specify one or more profiles in which the scene data should be saved. Note: objects are always saved separately from the scene data (use the "Save Configuration" behavior to customize the configuration of objects).'
),
_(
'Change save configuration of scene _PARAM1_: save it in the default save states: _PARAM2_ and in profiles: _PARAM3_'
),
_('Advanced configuration'),
'res/actions/saveDown.svg',
'res/actions/saveDown.svg'
)
.addCodeOnlyParameter('currentScene', '')
.addParameter(
'sceneName',
_('Scene name for which configuration should be changed'),
'',
false
)
.addParameter('yesorno', _('Persist in default save states'), '', false)
.setDefaultValue('yes')
.addParameter(
'string',
_('Profiles in which the scene data should be saved'),
'',
true
)
.setDefaultValue('')
.setParameterLongDescription(
_(
'Comma-separated list of profile names in which the scene data will be saved. When a save state is created with one or more profile names specified, the scene data will be saved only if it matches one of these profiles.'
)
)
.getCodeExtraInformation()
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
)
.setFunctionName('gdjs.saveState.setSceneDataSaveConfiguration');
// Save Configuration behavior
const saveConfigurationBehavior = new gd.BehaviorJsImplementation();
saveConfigurationBehavior.updateProperty = function (
behaviorContent,
propertyName,
newValue
) {
if (propertyName === 'defaultProfilePersistence') {
behaviorContent
.getChild('defaultProfilePersistence')
.setStringValue(newValue);
return true;
}
if (propertyName === 'persistedInProfiles') {
behaviorContent
.getChild('persistedInProfiles')
.setStringValue(newValue);
return true;
}
return false;
};
saveConfigurationBehavior.getProperties = function (behaviorContent) {
const behaviorProperties = new gd.MapStringPropertyDescriptor();
behaviorProperties
.getOrCreate('defaultProfilePersistence')
.setValue(
behaviorContent.getChild('defaultProfilePersistence').getStringValue()
)
.setType('Choice')
.setLabel(_('Persistence mode'))
.addChoice('Persisted', _('Include in save states (default)'))
.addChoice('DoNotSave', _('Do not save'));
behaviorProperties
.getOrCreate('persistedInProfiles')
.setValue(
behaviorContent.getChild('persistedInProfiles').getStringValue()
)
.setType('String')
.setLabel(_('Save profile names'))
.setDescription(
_(
'Comma-separated list of profile names in which the object is saved. When a save state is created with one or more profile names specified, the object will be saved only if it matches one of these profiles.'
)
)
.setAdvanced(true);
return behaviorProperties;
};
saveConfigurationBehavior.initializeContent = function (behaviorContent) {
behaviorContent
.addChild('defaultProfilePersistence')
.setStringValue('Persisted');
behaviorContent.addChild('persistedInProfiles').setStringValue('');
};
const sharedData = new gd.BehaviorsSharedData();
extension
.addBehavior(
'SaveConfiguration',
_('Save state configuration'),
'SaveConfiguration',
_('Allow the customize how the object is persisted in a save state.'),
'',
'res/actions/saveUp.svg',
'SaveConfiguration',
// @ts-ignore - TODO: Fix type being a BehaviorJsImplementation instead of an Behavior
saveConfigurationBehavior,
sharedData
)
.setQuickCustomizationVisibility(gd.QuickCustomization.Hidden)
.setIncludeFile('Extensions/SaveState/SaveStateTools.js')
.addIncludeFile(
'Extensions/SaveState/SaveConfigurationRuntimeBehavior.js'
);
return extension;
},
runExtensionSanityTests: function (gd, extension) {

View File

@@ -1,32 +0,0 @@
namespace gdjs {
// const logger = new gdjs.Logger('Save State');
export class SaveConfigurationRuntimeBehavior extends gdjs.RuntimeBehavior {
private readonly _defaultProfilePersistence: 'Persisted' | 'DoNotSave' =
'Persisted';
private readonly _persistedInProfiles = '';
constructor(
instanceContainer: gdjs.RuntimeInstanceContainer,
behaviorData: any,
owner: RuntimeObject
) {
super(instanceContainer, behaviorData, owner);
this._defaultProfilePersistence =
behaviorData.defaultProfilePersistence || 'Persisted';
this._persistedInProfiles = behaviorData.persistedInProfiles || '';
}
getDefaultProfilePersistence() {
return this._defaultProfilePersistence;
}
getPersistedInProfiles() {
return this._persistedInProfiles;
}
}
gdjs.registerBehavior(
'SaveState::SaveConfiguration',
gdjs.SaveConfigurationRuntimeBehavior
);
}

View File

@@ -1,696 +0,0 @@
namespace gdjs {
const logger = new gdjs.Logger('Save State');
const debugLogger = new gdjs.Logger('Save State - Debug');
// Comment this to see message logs and ease debugging:
gdjs.Logger.getDefaultConsoleLoggerOutput().discardGroup(
'Save State - Debug'
);
type ArbitrarySaveConfiguration = {
defaultProfilePersistence: 'Persisted' | 'DoNotSave';
persistedInProfiles: Set<string>;
};
export type RestoreRequestOptions = {
profileNames: string[];
clearSceneStack: boolean;
fromStorageName?: string;
fromVariable?: gdjs.Variable;
};
export namespace saveState {
export const getIndexedDbDatabaseName = () => {
const gameId = gdjs.projectData.properties.projectUuid;
return `gdevelop-game-${gameId}`;
};
export const getIndexedDbObjectStore = () => {
return `game-saves`;
};
export const getIndexedDbStorageKey = (key: string) => {
return `save-${key}`;
};
const variablesSaveConfiguration: WeakMap<
Variable,
ArbitrarySaveConfiguration
> = new WeakMap();
const runtimeSceneDataSaveConfiguration: WeakMap<
RuntimeGame,
Record<string, ArbitrarySaveConfiguration>
> = new WeakMap();
const runtimeGameDataSaveConfiguration: WeakMap<
RuntimeGame,
ArbitrarySaveConfiguration
> = new WeakMap();
export const setVariableSaveConfiguration = (
_: gdjs.RuntimeScene,
variable: gdjs.Variable,
persistInDefaultProfile: boolean,
persistedInProfilesAsString: string
) => {
variablesSaveConfiguration.set(variable, {
defaultProfilePersistence: persistInDefaultProfile
? 'Persisted'
: 'DoNotSave',
persistedInProfiles: new Set(
parseCommaSeparatedProfileNames(persistedInProfilesAsString)
),
});
};
export const setSceneDataSaveConfiguration = (
runtimeScene: gdjs.RuntimeScene,
sceneName: string,
persistInDefaultProfile: boolean,
persistedInProfilesAsString: string
) => {
const runtimeSceneDataSaveConfigurations =
runtimeSceneDataSaveConfiguration.get(runtimeScene.getGame()) || {};
runtimeSceneDataSaveConfiguration.set(runtimeScene.getGame(), {
...runtimeSceneDataSaveConfigurations,
[sceneName]: {
defaultProfilePersistence: persistInDefaultProfile
? 'Persisted'
: 'DoNotSave',
persistedInProfiles: new Set(
parseCommaSeparatedProfileNames(persistedInProfilesAsString)
),
},
});
};
export const setGameDataSaveConfiguration = (
runtimeScene: gdjs.RuntimeScene,
persistInDefaultProfile: boolean,
persistedInProfilesAsString: string
) => {
runtimeGameDataSaveConfiguration.set(runtimeScene.getGame(), {
defaultProfilePersistence: persistInDefaultProfile
? 'Persisted'
: 'DoNotSave',
persistedInProfiles: new Set(
parseCommaSeparatedProfileNames(persistedInProfilesAsString)
),
});
};
const checkIfIsPersistedInProfiles = (
profileNames: string[],
configuration: ArbitrarySaveConfiguration | null | undefined
) => {
if (profileNames.includes('default')) {
if (
!configuration ||
configuration.defaultProfilePersistence === 'Persisted'
) {
return true;
}
}
if (configuration) {
for (const profileName of profileNames) {
if (configuration.persistedInProfiles.has(profileName)) {
return true;
}
}
}
return false;
};
const makeIsVariableExcludedFromSaveState =
(profileNames: string[]) => (variable: gdjs.Variable) => {
const saveConfiguration = variablesSaveConfiguration.get(variable);
return !checkIfIsPersistedInProfiles(
profileNames,
saveConfiguration || null
);
};
let lastSaveTime: number | null = null;
let lastLoadTime: number | null = null;
let saveJustSucceeded: boolean = false;
let saveJustFailed: boolean = false;
let loadJustSucceeded: boolean = false;
let loadJustFailed: boolean = false;
let restoreRequestOptions: RestoreRequestOptions | null = null;
export const getSecondsSinceLastSave = (_: RuntimeScene): number => {
if (!lastSaveTime) return -1;
return Math.floor((Date.now() - lastSaveTime) / 1000);
};
export const getSecondsSinceLastLoad = (_: RuntimeScene): number => {
if (!lastLoadTime) return -1;
return Math.floor((Date.now() - lastLoadTime) / 1000);
};
export const hasSaveJustSucceeded = (_: RuntimeScene) => {
return saveJustSucceeded;
};
export const hasLoadJustSucceeded = (_: RuntimeScene) => {
return loadJustSucceeded;
};
export const hasSaveJustFailed = (_: RuntimeScene) => {
return saveJustFailed;
};
export const hasLoadJustFailed = (_: RuntimeScene) => {
return loadJustFailed;
};
export const markSaveJustSucceeded = (_: RuntimeScene) => {
saveJustSucceeded = true;
lastSaveTime = Date.now();
};
export const markLoadJustSucceeded = (_: RuntimeScene) => {
loadJustSucceeded = true;
lastLoadTime = Date.now();
};
export const markSaveJustFailed = (_: RuntimeScene) => {
saveJustFailed = true;
};
export const markLoadJustFailed = (_: RuntimeScene) => {
loadJustFailed = true;
};
// Ensure that the condition "save/load just succeeded/failed" are valid only for one frame.
gdjs.registerRuntimeScenePostEventsCallback(() => {
saveJustSucceeded = false;
saveJustFailed = false;
loadJustSucceeded = false;
loadJustFailed = false;
});
gdjs.registerRuntimeScenePostEventsCallback(
(runtimeScene: gdjs.RuntimeScene) => {
checkAndRestoreGameSaveStateAtEndOfFrame(runtimeScene);
}
);
/**
* Create a Save State from the given game.
*
* Only objects, variables etc... tagged with at least one of the profiles
* given in `options.profileNames` will be saved.
*/
export const createGameSaveState = (
runtimeGame: RuntimeGame,
options: {
profileNames: string[];
}
) => {
const { profileNames } = options;
const getNetworkSyncOptions: GetNetworkSyncDataOptions = {
syncObjectIdentifiers: true,
shouldExcludeVariableFromData:
makeIsVariableExcludedFromSaveState(profileNames),
syncAllBehaviors: true,
syncGameVariables: true,
syncSceneTimers: true,
syncOnceTriggers: true,
syncSounds: true,
syncTweens: true,
syncLayers: true,
syncAsyncTasks: true,
syncSceneVisualProps: true,
syncFullTileMaps: true,
};
const shouldPersistGameData = checkIfIsPersistedInProfiles(
options.profileNames,
runtimeGameDataSaveConfiguration.get(runtimeGame)
);
const gameSaveState: GameSaveState = {
// Always persist some game data, but limit it to just the scene stack
// if asked to not persist the game data.
gameNetworkSyncData: runtimeGame.getNetworkSyncData({
...getNetworkSyncOptions,
syncGameVariables: shouldPersistGameData,
syncSounds: shouldPersistGameData,
}),
layoutNetworkSyncDatas: [],
};
const scenes = runtimeGame.getSceneStack().getAllScenes();
scenes.forEach((runtimeScene, index) => {
gameSaveState.layoutNetworkSyncDatas[index] = {
sceneData: {} as LayoutNetworkSyncData,
objectDatas: {},
};
// First collect all object sync data, as they may generate unique
// identifiers like their networkId.
for (const object of runtimeScene.getAdhocListOfAllInstances()) {
// By default, an object which has no SaveConfiguration behavior is like
// it has the default profile persistence set to "Persisted".
let shouldPersist = profileNames.includes('default');
// @ts-ignore - access to `_behaviors` is an exceptional case for the SaveConfiguration behavior.
for (const behavior of object._behaviors) {
if (behavior instanceof gdjs.SaveConfigurationRuntimeBehavior) {
// This object has a SaveConfiguration behavior. Check if the configuration is set to
// persist it in one of the given profiles.
if (
(profileNames.includes('default') &&
behavior.getDefaultProfilePersistence() === 'Persisted') ||
profileNames.some((profileName) =>
// TODO: avoid do it for every single object instance?
behavior
.getPersistedInProfiles()
.split(',')
.map((profileName) => profileName.trim())
.includes(profileName)
)
) {
shouldPersist = true;
} else {
shouldPersist = false;
}
}
}
if (shouldPersist) {
const objectSyncData = object.getNetworkSyncData(
getNetworkSyncOptions
);
gameSaveState.layoutNetworkSyncDatas[index].objectDatas[object.id] =
objectSyncData;
}
}
// Collect scene data after the objects:
const shouldPersistSceneData = checkIfIsPersistedInProfiles(
options.profileNames,
(runtimeSceneDataSaveConfiguration.get(runtimeGame) || {})[
runtimeScene.getName()
]
);
if (shouldPersistSceneData) {
const sceneData = runtimeScene.getNetworkSyncData(
getNetworkSyncOptions
);
if (sceneData) {
gameSaveState.layoutNetworkSyncDatas[index].sceneData = sceneData;
}
}
});
return gameSaveState;
};
export const createGameSaveStateInVariable = async function (
runtimeScene: RuntimeScene,
variable: gdjs.Variable,
commaSeparatedProfileNames: string
) {
try {
const gameSaveState = createGameSaveState(runtimeScene.getGame(), {
profileNames: parseCommaSeparatedProfileNamesOrDefault(
commaSeparatedProfileNames
),
});
variable.fromJSObject(gameSaveState);
markSaveJustSucceeded(runtimeScene);
} catch (error) {
logger.error('Error saving to variable:', error);
markSaveJustFailed(runtimeScene);
}
};
export const createGameSaveStateInStorage = async function (
runtimeScene: RuntimeScene,
storageKey: string,
commaSeparatedProfileNames: string
) {
try {
const gameSaveState = createGameSaveState(runtimeScene.getGame(), {
profileNames: parseCommaSeparatedProfileNamesOrDefault(
commaSeparatedProfileNames
),
});
await gdjs.indexedDb.saveToIndexedDB(
getIndexedDbDatabaseName(),
getIndexedDbObjectStore(),
getIndexedDbStorageKey(storageKey),
gameSaveState
);
markSaveJustSucceeded(runtimeScene);
} catch (error) {
logger.error('Error saving to IndexedDB:', error);
markSaveJustFailed(runtimeScene);
}
};
const checkAndRestoreGameSaveStateAtEndOfFrame = function (
runtimeScene: RuntimeScene
) {
const runtimeGame = runtimeScene.getGame();
if (!restoreRequestOptions) return;
const { fromVariable, fromStorageName, profileNames, clearSceneStack } =
restoreRequestOptions;
// Reset it so we don't load it twice.
restoreRequestOptions = null;
if (fromVariable) {
const saveState = fromVariable.toJSObject();
try {
restoreGameSaveState(runtimeGame, saveState, {
profileNames,
clearSceneStack,
});
markLoadJustSucceeded(runtimeScene);
} catch (error) {
logger.error('Error loading from variable:', error);
markLoadJustFailed(runtimeScene);
}
} else if (fromStorageName) {
gdjs.indexedDb
.loadFromIndexedDB(
getIndexedDbDatabaseName(),
getIndexedDbObjectStore(),
getIndexedDbStorageKey(fromStorageName)
)
.then((jsonData) => {
const saveState = jsonData as GameSaveState;
restoreGameSaveState(runtimeGame, saveState, {
profileNames,
clearSceneStack,
});
markLoadJustSucceeded(runtimeScene);
})
.catch((error) => {
logger.error('Error loading from IndexedDB:', error);
markLoadJustFailed(runtimeScene);
});
}
};
const getInstanceFromNetworkId = ({
runtimeScene,
objectName,
networkId,
}: {
runtimeScene: gdjs.RuntimeScene;
objectName: string;
networkId: string;
}): gdjs.RuntimeObject | null => {
const instances = runtimeScene.getInstancesOf(objectName);
if (!instances) {
// object does not exist in the scene, cannot find the instance.
return null;
}
let instance =
instances.find((instance) => instance.networkId === networkId) || null;
// Check if there is already an instance with the given network ID.
if (instance) {
debugLogger.info(
`Found instance ${networkId}, will use it for restoring.`
);
return instance;
}
// Instance not found - it must have been deleted. Create it now.
debugLogger.info(
`Instance ${networkId} not found, creating instance ${objectName}.`
);
const newInstance = runtimeScene.createObject(objectName);
if (!newInstance) {
// Object does not exist in the scene, cannot create the instance.
return null;
}
newInstance.networkId = networkId;
return newInstance;
};
/**
* Restore the game using the given Save State.
*
* `options.profileNames` is the list of profiles to restore: only objects, variables etc... tagged with at least
* one of these profiles will be restored (or recreated if they don't exist, or deleted if not in the save state).
* Others will be left untouched.
*
* If `options.clearSceneStack` is true, all the scenes will be unloaded and re-created
* (meaning all instances will be re-created, variables will go back to their initial values, etc...).
* Otherwise, the existing scenes will be updated (or unloaded or created if the save state has different scenes).
*/
export const restoreGameSaveState = (
runtimeGame: RuntimeGame,
saveState: GameSaveState,
options: {
profileNames: string[];
clearSceneStack: boolean;
}
): void => {
const getObjectNamesToRestoreForRuntimeScene = (
runtimeScene: RuntimeScene
): Set<string> => {
const allObjectData = [];
runtimeScene._objects.values(allObjectData);
return getObjectNamesIncludedInProfiles(
allObjectData,
options.profileNames
);
};
const updateFromNetworkSyncDataOptions: UpdateFromNetworkSyncDataOptions =
{
clearSceneStack:
options.clearSceneStack === undefined
? true
: options.clearSceneStack,
getExcludedObjectNames: getObjectNamesToRestoreForRuntimeScene,
preventSoundsStoppingOnStartup: true,
clearInputs: true,
keepControl: true,
ignoreVariableOwnership: true,
shouldExcludeVariableFromUpdate: makeIsVariableExcludedFromSaveState(
options.profileNames
),
};
// First update the game, which will update the variables,
// and set the scene stack to update when ready.
if (saveState.gameNetworkSyncData) {
const shouldRestoreGameData = checkIfIsPersistedInProfiles(
options.profileNames,
runtimeGameDataSaveConfiguration.get(runtimeGame)
);
runtimeGame.updateFromNetworkSyncData(
shouldRestoreGameData
? saveState.gameNetworkSyncData
: {
// Disable game data restoration if asked to, but
// still always keep `ss` (scene stack) restoration as it's always needed.
ss: saveState.gameNetworkSyncData.ss,
},
updateFromNetworkSyncDataOptions
);
}
// Apply the scene stack updates, as we are at the end of a frame,
// we can safely do it.
const sceneStack = runtimeGame.getSceneStack();
sceneStack.applyUpdateFromNetworkSyncDataIfAny(
updateFromNetworkSyncDataOptions
);
// Then get all scenes, which we assume will be the expected ones
// after the load has been done, so we can update them,
// and create their objects.
const runtimeScenes = sceneStack.getAllScenes();
runtimeScenes.forEach((runtimeScene, index) => {
const layoutSyncData = saveState.layoutNetworkSyncDatas[index];
if (!layoutSyncData) return;
// List names of objects that must be restored
// (and only them - instances of others will be left alone).
const objectNamesToRestore =
getObjectNamesToRestoreForRuntimeScene(runtimeScene);
// Create objects first, so they are available for the scene update,
// especially so that they have a networkId defined.
const allLoadedNetworkIds = new Set<string>();
const objectDatas = layoutSyncData.objectDatas;
for (const id in objectDatas) {
const objectNetworkSyncData = objectDatas[id];
const objectName = objectNetworkSyncData.n;
if (!objectName) {
logger.warn('Tried to recreate an object without a name.');
continue;
}
if (!objectNamesToRestore.has(objectName)) {
// Object is in the save state, but not in the profiles to restore, don't restore it.
continue;
}
// Object is both in the save state and in the profiles to restore, restore it.
// Either find the existing instance with the same networkId, or create a new one.
const networkId = objectNetworkSyncData.networkId || '';
allLoadedNetworkIds.add(networkId);
const object = getInstanceFromNetworkId({
runtimeScene,
objectName: objectName,
networkId,
});
if (object) {
object.updateFromNetworkSyncData(
objectNetworkSyncData,
updateFromNetworkSyncDataOptions
);
}
}
// Clean instances of objects that are not in the profiles to restore but not in the save state
// (i.e: those who don't have a networkId, or it's not in the save state: they must not exist).
for (const objectName of objectNamesToRestore) {
// /!\ Clone the instances to avoid it being modified while iterating through them.
const objects = [...runtimeScene.getInstancesOf(objectName)];
for (const object of objects) {
// This is an object instance that is part of the object that are being restored,
// but it has not network id (created after the save state was created) or the network
// id is not in the save state: it's not part of the save state and must be deleted.
if (
!object.networkId ||
!allLoadedNetworkIds.has(object.networkId)
) {
object.deleteFromScene();
}
}
}
// Update the rest of the scene last.
if (
checkIfIsPersistedInProfiles(
options.profileNames,
(runtimeSceneDataSaveConfiguration.get(runtimeGame) || {})[
runtimeScene.getName()
]
)
) {
runtimeScene.updateFromNetworkSyncData(
layoutSyncData.sceneData,
updateFromNetworkSyncDataOptions
);
}
});
};
const parseCommaSeparatedProfileNames = (
commaSeparatedProfileNames: string
): string[] | null => {
if (!commaSeparatedProfileNames) return null;
return commaSeparatedProfileNames
.split(',')
.map((profileName) => profileName.trim());
};
const parseCommaSeparatedProfileNamesOrDefault = (
commaSeparatedProfileNames: string
): string[] => {
return (
parseCommaSeparatedProfileNames(commaSeparatedProfileNames) || [
'default',
]
);
};
export const restoreGameSaveStateFromVariable = async function (
_: gdjs.RuntimeScene,
variable: gdjs.Variable,
commaSeparatedProfileNames: string,
clearSceneStack: boolean
) {
// The information is saved, so that the restore can be done
// at the end of the frame,
// and avoid possible conflicts with running events.
restoreRequestOptions = {
fromVariable: variable,
profileNames: parseCommaSeparatedProfileNamesOrDefault(
commaSeparatedProfileNames
),
clearSceneStack,
};
};
export const restoreGameSaveStateFromStorage = async function (
_: gdjs.RuntimeScene,
storageName: string,
commaSeparatedProfileNames: string,
clearSceneStack: boolean
) {
// The information is saved, so that the restore can be done
// at the end of the frame,
// and avoid possible conflicts with running events.
restoreRequestOptions = {
fromStorageName: storageName,
profileNames: parseCommaSeparatedProfileNamesOrDefault(
commaSeparatedProfileNames
),
clearSceneStack,
};
};
/**
* Compute, by looking at the "static" object data (i.e: in the Project Data),
* the name of objects which must be restored, based on the given profiles.
*/
const getObjectNamesIncludedInProfiles = (
allObjectData: ObjectData[],
profileNames: string[]
): Set<string> => {
const objectNames = new Set<string>();
for (const objectData of allObjectData) {
// By default, an object which has no SaveConfiguration behavior is like
// it has the default profile persistence set to "Persisted".
let includedInProfiles = profileNames.includes('default');
for (const behaviorData of objectData.behaviors) {
if (behaviorData.type !== 'SaveState::SaveConfiguration') continue;
const defaultProfilePersistence =
behaviorData.defaultProfilePersistence === 'Persisted'
? 'Persisted'
: 'DoNotSave';
const persistedInProfiles =
typeof behaviorData.persistedInProfiles === 'string'
? behaviorData.persistedInProfiles
.split(',')
.map((profileName: string) => profileName.trim())
: [];
// This object has a SaveConfiguration behavior. Check if the configuration is set to
// persist it in one of the given profiles.
includedInProfiles = false;
if (
(profileNames.includes('default') &&
defaultProfilePersistence === 'Persisted') ||
profileNames.some((profileName) =>
persistedInProfiles.includes(profileName)
)
) {
// This object must be persisted in one of the given profile.
includedInProfiles = true;
}
}
if (includedInProfiles) {
objectNames.add(objectData.name);
}
}
return objectNames;
};
}
}

View File

@@ -0,0 +1,360 @@
namespace gdjs {
const logger = new gdjs.Logger('Save state');
export type LoadRequestOptions = {
loadStorageName?: string;
loadVariable?: gdjs.Variable;
};
export namespace saveState {
export const getIndexedDbDatabaseName = () => {
const gameId = gdjs.projectData.properties.projectUuid;
return `gdevelop-game-${gameId}`;
};
export const getIndexedDbObjectStore = () => {
return `game-saves`;
};
export const getIndexedDbStorageKey = (key: string) => {
return `save-${key}`;
};
const getNetworkSyncOptions: GetNetworkSyncDataOptions = {
syncObjectIdentifiers: true,
syncAllVariables: true,
syncAllBehaviors: true,
syncSceneTimers: true,
syncOnceTriggers: true,
syncSounds: true,
syncTweens: true,
syncLayers: true,
syncAsyncTasks: true,
syncSceneVisualProps: true,
syncFullTileMaps: true,
};
const updateFromNetworkSyncDataOptions: UpdateFromNetworkSyncDataOptions = {
clearSceneStack: true,
preventInitialInstancesCreation: true,
preventSoundsStoppingOnStartup: true,
clearInputs: true,
keepControl: true,
ignoreVariableOwnership: true,
};
let lastSaveTime: number | null = null;
let lastLoadTime: number | null = null;
let saveJustSucceeded: boolean = false;
let saveJustFailed: boolean = false;
let loadJustSucceeded: boolean = false;
let loadJustFailed: boolean = false;
let loadRequestOptions: LoadRequestOptions | null = null;
export const getSecondsSinceLastSave = (): number => {
if (!lastSaveTime) return -1;
return Math.floor((Date.now() - lastSaveTime) / 1000);
};
export const getSecondsSinceLastLoad = (): number => {
if (!lastLoadTime) return -1;
return Math.floor((Date.now() - lastLoadTime) / 1000);
};
export const hasSaveJustSucceeded = () => {
return saveJustSucceeded;
};
export const hasLoadJustSucceeded = () => {
return loadJustSucceeded;
};
export const hasSaveJustFailed = () => {
return saveJustFailed;
};
export const hasLoadJustFailed = () => {
return loadJustFailed;
};
export const markSaveJustSucceeded = () => {
saveJustSucceeded = true;
lastSaveTime = Date.now();
};
export const markLoadJustSucceeded = () => {
loadJustSucceeded = true;
lastLoadTime = Date.now();
};
export const markSaveJustFailed = () => {
saveJustFailed = true;
};
export const markLoadJustFailed = () => {
loadJustFailed = true;
};
// Ensure that the condition "save/load just succeeded/failed" are valid only for one frame.
gdjs.registerRuntimeScenePostEventsCallback(() => {
saveJustSucceeded = false;
saveJustFailed = false;
loadJustSucceeded = false;
loadJustFailed = false;
});
gdjs.registerRuntimeScenePostEventsCallback(
(runtimeScene: gdjs.RuntimeScene) => {
loadGameSnapshotAtTheEndOfFrameIfAny(runtimeScene);
}
);
const getGameSaveState = (runtimeScene: RuntimeScene) => {
const gameSaveState: GameSaveState = {
gameNetworkSyncData: {},
layoutNetworkSyncDatas: [],
};
const gameData = runtimeScene
.getGame()
.getNetworkSyncData(getNetworkSyncOptions);
const scenes = runtimeScene.getGame().getSceneStack().getAllScenes();
gameSaveState.gameNetworkSyncData = gameData || {};
scenes.forEach((scene, index) => {
gameSaveState.layoutNetworkSyncDatas[index] = {
sceneData: {} as LayoutNetworkSyncData,
objectDatas: {},
};
// First collect all object sync data, as they may generate unique
// identifiers like their networkId.
const sceneRuntimeObjects = scene.getAdhocListOfAllInstances();
for (const key in sceneRuntimeObjects) {
if (sceneRuntimeObjects.hasOwnProperty(key)) {
const object = sceneRuntimeObjects[key];
const objectSyncData = object.getNetworkSyncData(
getNetworkSyncOptions
);
gameSaveState.layoutNetworkSyncDatas[index].objectDatas[object.id] =
objectSyncData;
}
}
// Collect all scene data in the end.
const sceneDatas = (scene.getNetworkSyncData(getNetworkSyncOptions) ||
[]) as LayoutNetworkSyncData;
gameSaveState.layoutNetworkSyncDatas[index].sceneData = sceneDatas;
});
return gameSaveState;
};
export const saveVariableGameSnapshot = async function (
currentScene: RuntimeScene,
variable: gdjs.Variable
) {
try {
const gameSaveState = getGameSaveState(currentScene);
variable.fromJSObject(gameSaveState);
markSaveJustSucceeded();
} catch (error) {
logger.error('Error saving to variable:', error);
markSaveJustFailed();
}
};
export const saveStorageGameSnapshot = async function (
currentScene: RuntimeScene,
storageKey: string
) {
try {
const gameSaveState = getGameSaveState(currentScene);
await gdjs.indexedDb.saveToIndexedDB(
getIndexedDbDatabaseName(),
getIndexedDbObjectStore(),
getIndexedDbStorageKey(storageKey),
gameSaveState
);
markSaveJustSucceeded();
} catch (error) {
logger.error('Error saving to IndexedDB:', error);
markSaveJustFailed();
}
};
export const loadGameFromVariableSnapshot = async function (
variable: gdjs.Variable
) {
// The information is saved, so that the load can be done
// at the end of the frame,
// and avoid possible conflicts with running events.
loadRequestOptions = {
loadVariable: variable,
};
};
export const loadGameFromStorageSnapshot = async function (
storageName: string
) {
// The information is saved, so that the load can be done
// at the end of the frame,
// and avoid possible conflicts with running events.
loadRequestOptions = {
loadStorageName: storageName,
};
};
const loadGameSnapshotAtTheEndOfFrameIfAny = function (
runtimeScene: RuntimeScene
) {
if (!loadRequestOptions) return;
const optionsToApply = loadRequestOptions;
// Reset it so we don't load it twice.
loadRequestOptions = null;
if (optionsToApply.loadVariable) {
const sceneVariables = runtimeScene.getVariables();
const variablePathInScene =
sceneVariables.getVariablePathInContainerByLoopingThroughAllVariables(
optionsToApply.loadVariable
);
const gameVariables = runtimeScene.getGame().getVariables();
const variablePathIngame =
gameVariables.getVariablePathInContainerByLoopingThroughAllVariables(
optionsToApply.loadVariable
);
const saveState =
optionsToApply.loadVariable.toJSObject() as GameSaveState;
try {
loadGameFromSave(runtimeScene, saveState, {
variableToRehydrate: optionsToApply.loadVariable,
variablePathInScene: variablePathInScene,
variablePathInGame: variablePathIngame,
});
markLoadJustSucceeded();
} catch (error) {
logger.error('Error loading from variable:', error);
markLoadJustFailed();
}
} else if (optionsToApply.loadStorageName) {
gdjs.indexedDb
.loadFromIndexedDB(
getIndexedDbDatabaseName(),
getIndexedDbObjectStore(),
getIndexedDbStorageKey(optionsToApply.loadStorageName)
)
.then((jsonData) => {
const saveState = jsonData as GameSaveState;
loadGameFromSave(runtimeScene, saveState);
markLoadJustSucceeded();
})
.catch((error) => {
logger.error('Error loading from IndexedDB:', error);
markLoadJustFailed();
});
}
};
const loadGameFromSave = (
runtimeScene: RuntimeScene,
saveState: GameSaveState,
saveOptions?: {
variableToRehydrate: gdjs.Variable;
variablePathInScene: string[] | null;
variablePathInGame: string[] | null;
}
): void => {
// Save the content of the save, as it will be erased after the load.
const variableToRehydrateNetworkSyncData = saveOptions
? saveOptions.variableToRehydrate.getNetworkSyncData(
getNetworkSyncOptions
)
: null;
// First update the game, which will update the variables,
// and set the scene stack to update when ready.
const runtimeGame = runtimeScene.getGame();
runtimeGame.updateFromNetworkSyncData(
saveState.gameNetworkSyncData,
updateFromNetworkSyncDataOptions
);
// Apply the scene stack updates, as we are at the end of a frame,
// we can safely do it.
const sceneStack = runtimeGame.getSceneStack();
sceneStack.applyUpdateFromNetworkSyncDataIfAny(
updateFromNetworkSyncDataOptions
);
// Then get all scenes, which we assume will be the expected ones
// after the load has been done, so we can update them,
// and create their objects.
const runtimeScenes = sceneStack.getAllScenes();
runtimeScenes.forEach((scene, index) => {
const layoutSyncData = saveState.layoutNetworkSyncDatas[index];
if (!layoutSyncData) return;
// Create objects first, so they are available for the scene update,
// especially so that they have a networkId defined.
const objectDatas = layoutSyncData.objectDatas;
for (const id in objectDatas) {
const objectNetworkSyncData = objectDatas[id];
const objectName = objectNetworkSyncData.n;
if (!objectName) {
logger.warn('Tried to recreate an object without a name.');
continue;
}
const object = scene.createObject(objectName);
if (object) {
object.updateFromNetworkSyncData(
objectNetworkSyncData,
updateFromNetworkSyncDataOptions
);
}
}
// Update the scene last.
scene.updateFromNetworkSyncData(
layoutSyncData.sceneData,
updateFromNetworkSyncDataOptions
);
});
// Finally, if the save was done in a variable,
// rehydrate the variable where the save was done,
// as it has been erased by the load.
if (saveOptions && variableToRehydrateNetworkSyncData) {
const currentScene = sceneStack.getCurrentScene();
if (!currentScene) return;
const sceneVariables = currentScene.getVariables();
const gameVariables = currentScene.getGame().getVariables();
const { variablePathInScene, variablePathInGame } = saveOptions;
if (variablePathInScene && variablePathInScene.length > 0) {
const variableName =
variablePathInScene[variablePathInScene.length - 1];
const variableInScene =
sceneVariables.getVariableFromPath(variablePathInScene);
if (variableInScene) {
const variableNetworkSyncData: VariableNetworkSyncData = {
name: variableName,
...variableToRehydrateNetworkSyncData,
};
variableInScene.updateFromNetworkSyncData(
variableNetworkSyncData,
updateFromNetworkSyncDataOptions
);
}
}
if (variablePathInGame && variablePathInGame.length > 0) {
const variableName =
variablePathInGame[variablePathInGame.length - 1];
const variableInGame =
gameVariables.getVariableFromPath(variablePathInGame);
if (variableInGame) {
const variableNetworkSyncData: VariableNetworkSyncData = {
name: variableName,
...variableToRehydrateNetworkSyncData,
};
variableInGame.updateFromNetworkSyncData(
variableNetworkSyncData,
updateFromNetworkSyncDataOptions
);
}
}
}
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -53,8 +53,6 @@ namespace gdjs {
readonly spineResourceName: string;
static isHitBoxesUpdateDisabled = false;
/**
* @param instanceContainer The container the object belongs to.
* @param objectData The object data used to initialize the object
@@ -76,10 +74,6 @@ namespace gdjs {
this.setAnimationIndex(0);
this._renderer.updateAnimation(0);
if (SpineRuntimeObject.isHitBoxesUpdateDisabled) {
this.hitBoxes.length = 0;
}
// *ALWAYS* call `this.onCreated()` at the very end of your object constructor.
this.onCreated();
}
@@ -194,14 +188,6 @@ namespace gdjs {
}
}
updateHitBoxes(): void {
if (SpineRuntimeObject.isHitBoxesUpdateDisabled) {
return;
}
super.updateHitBoxes();
}
extraInitializationFromInitialInstance(
initialInstanceData: InstanceData
): void {

View File

@@ -53,7 +53,6 @@ BaseObjectExtension::BaseObjectExtension() {
"runtimeobject.js");
objectActions["RotateTowardAngle"].SetFunctionName("rotateTowardAngle");
objectActions["RotateTowardPosition"].SetFunctionName("rotateTowardPosition");
objectActions["RotateTowardObject"].SetFunctionName("rotateTowardObject");
objectActions["ChangeLayer"].SetFunctionName("setLayer");
objectConditions["Layer"].SetFunctionName("isOnLayer");
objectActions["ChangePlan"]
@@ -230,8 +229,6 @@ BaseObjectExtension::BaseObjectExtension() {
"gdjs.evtTools.object.pickRandomObject");
GetAllConditions()["AjoutHasard"].SetFunctionName(
"gdjs.evtTools.object.pickRandomObject");
GetAllActions()["PickNearest"].SetFunctionName(
"gdjs.evtTools.object.pickNearestObject");
GetAllConditions()["PickNearest"].SetFunctionName(
"gdjs.evtTools.object.pickNearestObject");

View File

@@ -100,18 +100,15 @@ namespace gdjs {
getNetworkSyncData(
syncOptions: GetNetworkSyncDataOptions
): AsyncTasksManagerNetworkSyncData {
const tasksData = this.tasksWithCallback
.map(({ asyncTask, callbackId, longLivedObjectsList }) => {
// Stay compatible with old extensions declaring tasks without a getNetworkSyncData method.
if (!asyncTask.getNetworkSyncData) return null;
const tasksData = this.tasksWithCallback.map(
({ asyncTask, callbackId, longLivedObjectsList }) => {
return {
callbackId,
asyncTask: asyncTask.getNetworkSyncData(),
objectsList: longLivedObjectsList.getNetworkSyncData(syncOptions),
};
})
.filter(Boolean);
}
);
return {
tasks: tasksData,

View File

@@ -312,15 +312,15 @@ namespace gdjs {
xPos: float,
yPos: float,
zPos: float,
trackByPersistentUuid: boolean,
options?: {
excludedObjectNames?: Set<string> | null;
}
trackByPersistentUuid: boolean
): void {
let zOffset: number = zPos;
let shouldTrackByPersistentUuid: boolean = trackByPersistentUuid;
let zOffset: number;
let shouldTrackByPersistentUuid: boolean;
if (arguments.length <= 4) {
if (arguments.length === 5) {
zOffset = zPos;
shouldTrackByPersistentUuid = trackByPersistentUuid;
} else {
/**
* Support for the previous signature (before 3D was introduced):
* createObjectsFrom(data, xPos, yPos, trackByPersistentUuid)
@@ -332,10 +332,6 @@ namespace gdjs {
for (let i = 0, len = data.length; i < len; ++i) {
const instanceData = data[i];
const objectName = instanceData.name;
if (options?.excludedObjectNames?.has(objectName)) {
continue;
}
const newObject = this.createObject(objectName);
if (newObject !== null) {
if (shouldTrackByPersistentUuid) {
@@ -613,7 +609,7 @@ namespace gdjs {
* @param name Name of the object for which the instances must be returned.
* @return The list of objects with the given name
*/
getObjects(name: string): gdjs.RuntimeObject[] {
getObjects(name: string): gdjs.RuntimeObject[] | undefined {
if (!this._instances.containsKey(name)) {
logger.info(
'RuntimeScene.getObjects: No instances called "' +
@@ -640,20 +636,18 @@ namespace gdjs {
return null;
}
const objectData = this._objects.get(objectName);
// Create a new object using the object constructor (cached during loading)
// and the stored object's data:
const cache = this._instancesCache.get(objectName);
const ctor = this._objectsCtor.get(objectName);
let obj;
if (!cache || cache.length === 0) {
obj = new ctor(this, objectData);
obj = new ctor(this, this._objects.get(objectName));
} else {
// Reuse an objet destroyed before. If there is an object in the cache,
// then it means it does support reinitialization.
obj = cache.pop();
obj.reinitialize(objectData);
obj.reinitialize(this._objects.get(objectName));
}
this.addObject(obj);
return obj;

View File

@@ -348,7 +348,7 @@ namespace gdjs {
// It can't actually happen.
this._logs.push({
kind: 'error',
message: "Can't hot-reload as no scene is opened.",
message: "Can't hot-reload as no scene are opened.",
});
return;
}

View File

@@ -410,12 +410,7 @@ namespace gdjs {
return true;
};
export const pickNearestObject = function (
objectsLists: ObjectsLists,
x: float,
y: float,
inverted?: boolean | undefined
) {
export const pickNearestObject = function (objectsLists, x, y, inverted) {
let bestObject = null;
let best = 0;
let first = true;
@@ -426,7 +421,8 @@ namespace gdjs {
for (let j = 0; j < list.length; ++j) {
const object = list[j];
const distance = object.getSqDistanceToPosition(x, y);
if (first || distance < best !== inverted) {
// @ts-ignore
if (first || (distance < best) ^ inverted) {
best = distance;
bestObject = object;
}

View File

@@ -1384,29 +1384,24 @@ namespace gdjs {
syncOptions: GetNetworkSyncDataOptions
): GameNetworkSyncData | null {
const syncData: GameNetworkSyncData = {
var:
syncOptions.syncGameVariables === false
? undefined
: this._variables.getNetworkSyncData(syncOptions),
var: this._variables.getNetworkSyncData(syncOptions),
sm: syncOptions.syncSounds
? this.getSoundManager().getNetworkSyncData()
: undefined,
ss: this._sceneStack.getNetworkSyncData(syncOptions) || undefined,
};
if (syncOptions.syncGameVariables !== false) {
const extensionsVariablesSyncData = {};
this._variablesByExtensionName.forEach((variables, extensionName) => {
const extensionVariablesSyncData =
variables.getNetworkSyncData(syncOptions);
// If there is no variables to sync, don't include the extension in the sync data.
if (extensionVariablesSyncData.length) {
extensionsVariablesSyncData[extensionName] =
extensionVariablesSyncData;
}
});
syncData.extVar = extensionsVariablesSyncData;
}
const extensionsVariablesSyncData = {};
this._variablesByExtensionName.forEach((variables, extensionName) => {
const extensionVariablesSyncData =
variables.getNetworkSyncData(syncOptions);
// If there is no variables to sync, don't include the extension in the sync data.
if (extensionVariablesSyncData.length) {
extensionsVariablesSyncData[extensionName] =
extensionVariablesSyncData;
}
});
syncData.extVar = extensionsVariablesSyncData;
if (
(!syncData.var || syncData.var.length === 0) &&

View File

@@ -698,7 +698,7 @@ namespace gdjs {
}
/**
* @return The internal object for a 3D rendering (THREE.Object3D...)
* @return The internal object for a 3D rendering (PIXI.DisplayObject...)
*/
get3DRendererObject(): THREE.Object3D | null | undefined {
return undefined;
@@ -842,12 +842,6 @@ namespace gdjs {
return this.getY();
}
/**
* Rotate the object towards another object position.
* @param x The target x position
* @param y The target y position
* @param speed The rotation speed. 0 for an immediate rotation to the target position.
*/
rotateTowardPosition(x: float, y: float, speed: float): void {
this.rotateTowardAngle(
gdjs.toDegrees(
@@ -861,24 +855,8 @@ namespace gdjs {
}
/**
* Rotate the object towards another object position (aiming at the center of the object).
* @param target The target object
* @param speed The rotation speed. 0 for an immediate rotation to the target object.
*/
rotateTowardObject(target: gdjs.RuntimeObject | null, speed: float): void {
if (target === null) {
return;
}
this.rotateTowardPosition(
target.getDrawableX() + target.getCenterX(),
target.getDrawableY() + target.getCenterY(),
speed
);
}
/**
* @param angle The targeted angle.
* @param speed The rotation speed. 0 for an immediate rotation to the target angle.
* @param angle The targeted direction angle.
* @param speed The rotation speed.
*/
rotateTowardAngle(angle: float, speed: float): void {
if (speed === 0) {

View File

@@ -134,7 +134,7 @@ namespace gdjs {
loadFromScene(
sceneAndExtensionsData: SceneAndExtensionsData | null,
options?: {
excludedObjectNames?: Set<string>;
skipCreatingInstances?: boolean;
skipStoppingSoundsOnStartup?: boolean;
}
) {
@@ -195,18 +195,16 @@ namespace gdjs {
this.registerObject(sceneData.objects[i]);
}
// Create initial instances of objects.
this.createObjectsFrom(
sceneData.instances,
0,
0,
0,
/*trackByPersistentUuid=*/
true,
{
excludedObjectNames: options?.excludedObjectNames,
}
);
// Create initial instances of objects
if (!options || !options.skipCreatingInstances)
this.createObjectsFrom(
sceneData.instances,
0,
0,
0,
/*trackByPersistentUuid=*/
true
);
// Set up the default z order (for objects created from events)
this._setLayerDefaultZOrders();

View File

@@ -1,15 +1,11 @@
namespace gdjs {
const logger = new gdjs.Logger('Scene stack');
const debugLogger = new gdjs.Logger('Scene stack - Debug');
// Comment this to see message logs and ease debugging:
gdjs.Logger.getDefaultConsoleLoggerOutput().discardGroup(
'Scene stack - Debug'
);
const debugLogger = new gdjs.Logger('Multiplayer - Debug');
interface PushSceneOptions {
sceneName: string;
externalLayoutName?: string;
getExcludedObjectNames?: (runtimeScene: RuntimeScene) => Set<string>;
skipCreatingInstancesFromScene?: boolean;
skipStoppingSoundsOnStartup?: boolean;
}
@@ -150,10 +146,10 @@ namespace gdjs {
const sceneName =
typeof options === 'string' ? options : options.sceneName;
const getExcludedObjectNames =
const skipCreatingInstancesFromScene =
typeof options === 'string'
? undefined
: options.getExcludedObjectNames;
? false
: options.skipCreatingInstancesFromScene;
const skipStoppingSoundsOnStartup =
typeof options === 'string'
? false
@@ -174,7 +170,7 @@ namespace gdjs {
return this._loadNewScene({
sceneName,
externalLayoutName,
getExcludedObjectNames,
skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup,
});
}
@@ -184,7 +180,7 @@ namespace gdjs {
this._loadNewScene({
sceneName,
externalLayoutName,
getExcludedObjectNames,
skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup,
});
this._isNextLayoutLoading = false;
@@ -201,9 +197,7 @@ namespace gdjs {
newScene.loadFromScene(
this._runtimeGame.getSceneAndExtensionsData(options.sceneName),
{
excludedObjectNames: options.getExcludedObjectNames
? options.getExcludedObjectNames(newScene)
: undefined,
skipCreatingInstances: options.skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup: options.skipStoppingSoundsOnStartup,
}
);
@@ -339,7 +333,8 @@ namespace gdjs {
this._sceneStackSyncDataToApply = null;
const getExcludedObjectNames = options && options.getExcludedObjectNames;
const skipCreatingInstancesFromScene =
!!options && !!options.preventInitialInstancesCreation;
const skipStoppingSoundsOnStartup =
!!options && !!options.preventSoundsStoppingOnStartup;
@@ -354,7 +349,7 @@ namespace gdjs {
const sceneSyncData = sceneStackSyncData[i];
const newScene = this.push({
sceneName: sceneSyncData.name,
getExcludedObjectNames,
skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup,
});
if (newScene) {
@@ -364,7 +359,6 @@ namespace gdjs {
hasMadeChangeToStack = true;
return hasMadeChangeToStack;
}
// If this method is called, we are a client.
// We trust the host to be the source of truth for the scene stack.
// So we loop through the scenes in the stack given by the host and either:
@@ -380,10 +374,9 @@ namespace gdjs {
`Scene at position ${i} with name ${sceneSyncData.name} is missing from the stack, adding it.`
);
// We have fewer scenes in the stack than the host, let's add the scene.
// By definition, there is nothing to clear because we're already at the top of the stack.
const newScene = this.push({
sceneName: sceneSyncData.name,
getExcludedObjectNames,
skipCreatingInstancesFromScene,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;
@@ -397,23 +390,16 @@ namespace gdjs {
debugLogger.info(
`Scene at position ${i} and name ${sceneAtThisPositionInOurStack.getName()} is not the same as the expected ${
sceneSyncData.name
}, replacing it.`
}, replacing.`
);
// The scene does not correspond to the scene at this position in our stack
// Let's unload everything after this position to reconstruct the stack.
if (this._stack.length > i + 1) {
debugLogger.info(
`Unloading ${this._stack.length - (i + 1)} scenes after position ${i}.`
);
this.pop(this._stack.length - (i + 1));
}
// Let's unload everything after this position to recreate the stack.
const newScene = this.replace({
sceneName: sceneSyncData.name,
clear: false,
getExcludedObjectNames,
clear: true,
skipCreatingInstancesFromScene,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;
}
@@ -447,26 +433,16 @@ namespace gdjs {
debugLogger.info(
`Scene at position ${i} and name ${sceneAtThisPositionInOurStack.getName()} has a different networkId ${
sceneAtThisPositionInOurStack.networkId
} than the expected ${sceneSyncData.networkId}, replacing it.`
} than the expected ${sceneSyncData.networkId}, replacing.`
);
// The scene is in the stack but has a different networkId
// This can happen if the host has restarted the scene
// We can't just update the networkId of the scene in the stack
// We need to replace it with a new scene.
// Like for the case where it's a totally different scene,
// we need to unload everything after this position to reconstruct the stack.
if (this._stack.length > i + 1) {
debugLogger.info(
`Unloading ${this._stack.length - (i + 1)} scenes after position ${i}.`
);
this.pop(this._stack.length - (i + 1));
}
// We need to replace it with a new scene
const newScene = this.replace({
sceneName: sceneSyncData.name,
clear: false,
getExcludedObjectNames,
skipCreatingInstancesFromScene,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;

View File

@@ -953,7 +953,14 @@ namespace gdjs {
* @deprecated
*/
turnTowardObject(obj: gdjs.RuntimeObject | null) {
return this.rotateTowardObject(obj, 0);
if (obj === null) {
return;
}
this.rotateTowardPosition(
obj.getDrawableX() + obj.getCenterX(),
obj.getDrawableY() + obj.getCenterY(),
0
);
}
}
gdjs.registerObject(

View File

@@ -76,32 +76,3 @@ declare namespace gdjs {
/** The global cc object from Cocos2D-Js. */
declare var cc: any;
/**
* Fixes https://github.com/microsoft/TypeScript/issues/16655 for `Array.prototype.filter()`
* For example, using the fix the type of `bar` is `string[]` in the below snippet as it should be.
*
* const foo: (string | null | undefined)[] = [];
* const bar = foo.filter(Boolean);
*
* For related definitions, see https://github.com/microsoft/TypeScript/blob/master/src/lib/es5.d.ts
*
* Original licenses apply, see
* - https://github.com/microsoft/TypeScript/blob/master/LICENSE.txt
* - https://stackoverflow.com/help/licensing
*/
/** See https://stackoverflow.com/a/51390763/1470607 */
type Falsy = false | 0 | '' | null | undefined;
interface Array<T> {
/**
* Returns the elements of an array that meet the condition specified in a callback function.
* @param predicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.
* @param thisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
*/
filter<S extends T>(
predicate: BooleanConstructor,
thisArg?: any
): Exclude<S, Falsy>[];
}

View File

@@ -43,9 +43,8 @@ declare type GetNetworkSyncDataOptions = {
playerNumber?: number;
isHost?: boolean;
syncObjectIdentifiers?: boolean;
shouldExcludeVariableFromData?: (variable: Variable) => boolean;
syncAllVariables?: boolean;
syncAllBehaviors?: boolean;
syncGameVariables?: boolean;
syncSceneTimers?: boolean;
syncOnceTriggers?: boolean;
syncSounds?: boolean;
@@ -58,12 +57,11 @@ declare type GetNetworkSyncDataOptions = {
declare type UpdateFromNetworkSyncDataOptions = {
clearSceneStack?: boolean;
getExcludedObjectNames?: (runtimeScene: RuntimeScene) => Set<string>;
preventInitialInstancesCreation?: boolean;
preventSoundsStoppingOnStartup?: boolean;
clearInputs?: boolean;
keepControl?: boolean;
ignoreVariableOwnership?: boolean;
shouldExcludeVariableFromUpdate?: (variable: Variable) => boolean;
};
/** Object containing basic properties for all objects synchronizing over the network. */

View File

@@ -4,6 +4,6 @@ declare type SceneSaveState = {
};
declare type GameSaveState = {
gameNetworkSyncData: GameNetworkSyncData | null;
gameNetworkSyncData: GameNetworkSyncData;
layoutNetworkSyncDatas: SceneSaveState[];
};

View File

@@ -126,38 +126,26 @@ namespace gdjs {
getNetworkSyncData(
syncOptions: GetNetworkSyncDataOptions
): UnnamedVariableNetworkSyncData | undefined {
const syncedPlayerNumber = syncOptions.playerNumber;
const isHost = syncOptions.isHost;
const variableOwner = this.getPlayerOwnership();
if (
// Variable undefined.
this.isUndefinedInContainer()
)
return;
const variableOwner = this.getPlayerOwnership();
if (syncOptions.shouldExcludeVariableFromData) {
// Saving for "save state": serialize all variables unless excluded.
if (syncOptions.shouldExcludeVariableFromData(this)) {
return;
}
} else {
// Saving for "multiplayer": only serialize the variable if owned by the player.
const syncedPlayerNumber = syncOptions.playerNumber;
const isHost = syncOptions.isHost;
if (
this.isUndefinedInContainer() ||
// If we force sync everything, we don't look at the ownership.
(!syncOptions.syncAllVariables &&
// Variable marked as not to be synchronized.
variableOwner === null ||
// Getting sync data for a specific player:
(syncedPlayerNumber !== undefined &&
// Variable is owned by host but this player number is not the host.
variableOwner === 0 &&
!isHost) ||
// Variable is owned by a player but not getting sync data for this player number.
(variableOwner !== 0 && syncedPlayerNumber !== variableOwner)
) {
// In those cases, the variable should not be synchronized.
return;
}
(variableOwner === null ||
// Getting sync data for a specific player:
(syncedPlayerNumber !== undefined &&
// Variable is owned by host but this player number is not the host.
variableOwner === 0 &&
!isHost) ||
// Variable is owned by a player but not getting sync data for this player number.
(variableOwner !== 0 && syncedPlayerNumber !== variableOwner)))
) {
// In those cases, the variable should not be synchronized.
return;
}
const variableType = this.getType();
@@ -264,13 +252,6 @@ namespace gdjs {
const variableData =
gdjs.Variable.getVariableDataFromNetworkSyncData(networkSyncData);
if (
options.shouldExcludeVariableFromUpdate &&
options.shouldExcludeVariableFromUpdate(this)
) {
return;
}
if (!options.ignoreVariableOwnership) {
const currentPlayerNumber = gdjs.multiplayer.getCurrentPlayerNumber();

View File

@@ -1,7 +1,7 @@
{
"firstLayout": "",
"gdVersion": {
"build": 242,
"build": 241,
"major": 5,
"minor": 5,
"revision": 0
@@ -362,8 +362,7 @@
"gridAlpha": 0.8,
"snap": false,
"zoomFactor": 0.4633111824514028,
"windowMask": false,
"selectedLayer": ""
"windowMask": false
},
"objectsGroups": [],
"variables": [
@@ -404,11 +403,6 @@
"name": "Variable",
"type": "number",
"value": 0
},
{
"name": "VariableExcludedFromSave",
"type": "number",
"value": 10
}
],
"instances": [
@@ -454,8 +448,8 @@
"name": "Joystick",
"persistentUuid": "855d809a-f954-4072-86d5-1d6f0b24a066",
"width": 0,
"x": 131,
"y": 604,
"x": 176,
"y": 576,
"zOrder": 2,
"numberProperties": [],
"stringProperties": [],
@@ -844,118 +838,6 @@
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "Background",
"name": "NewText7",
"persistentUuid": "02ff696e-6d03-466c-9e4a-f993c4c45958",
"width": 0,
"x": 159,
"y": 35,
"zOrder": 39,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "",
"name": "Coins",
"persistentUuid": "749601b7-c699-47a6-81b8-d19b7cd5bca9",
"width": 0,
"x": 704,
"y": 403,
"zOrder": 40,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "",
"name": "Coins",
"persistentUuid": "502f29fe-fca1-4625-b194-49a701bda9cd",
"width": 0,
"x": 723,
"y": 323,
"zOrder": 41,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "",
"name": "Coins",
"persistentUuid": "65d4f46c-339a-4a2b-9870-799566c2456b",
"width": 0,
"x": 846,
"y": 205,
"zOrder": 42,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "",
"name": "Coins",
"persistentUuid": "3e848abb-3ab3-48ce-9176-23cd9be84fde",
"width": 0,
"x": 1094,
"y": 185,
"zOrder": 43,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "",
"name": "Coins",
"persistentUuid": "47351cf9-0485-4388-958c-94024dd13e9f",
"width": 0,
"x": 1219,
"y": 263,
"zOrder": 44,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
},
{
"angle": 0,
"customSize": false,
"height": 0,
"keepRatio": true,
"layer": "",
"name": "Coins",
"persistentUuid": "8b305063-3f81-4ef4-accc-1f36ad1bed2d",
"width": 0,
"x": 1228,
"y": 362,
"zOrder": 45,
"numberProperties": [],
"stringProperties": [],
"initialVariables": []
}
],
"objects": [
@@ -1119,14 +1001,7 @@
"updateIfNotVisible": false,
"variables": [],
"effects": [],
"behaviors": [
{
"name": "SaveConfiguration",
"type": "SaveState::SaveConfiguration",
"defaultProfilePersistence": "Persisted",
"persistedInProfiles": "coins"
}
],
"behaviors": [],
"animations": [
{
"name": "",
@@ -1284,14 +1159,7 @@
"variant": "",
"variables": [],
"effects": [],
"behaviors": [
{
"name": "SaveConfiguration",
"type": "SaveState::SaveConfiguration",
"defaultProfilePersistence": "DoNotSave",
"persistedInProfiles": ""
}
],
"behaviors": [],
"content": {},
"childrenContent": {
"Border": {
@@ -2145,49 +2013,6 @@
"lineHeight": 0,
"color": "0;0;0"
}
},
{
"assetStoreId": "",
"bold": false,
"italic": false,
"name": "NewText7",
"smoothed": true,
"type": "TextObject::Text",
"underlined": false,
"variables": [],
"effects": [],
"behaviors": [],
"string": "Excluded Variable",
"font": "",
"textAlignment": "left",
"characterSize": 20,
"color": {
"b": 0,
"g": 0,
"r": 0
},
"content": {
"bold": false,
"isOutlineEnabled": false,
"isShadowEnabled": false,
"italic": false,
"outlineColor": "255;255;255",
"outlineThickness": 2,
"shadowAngle": 90,
"shadowBlurRadius": 2,
"shadowColor": "0;0;0",
"shadowDistance": 4,
"shadowOpacity": 127,
"smoothed": true,
"underlined": false,
"text": "Excluded Variable",
"font": "",
"textAlignment": "left",
"verticalTextAlignment": "top",
"characterSize": 20,
"lineHeight": 0,
"color": "0;0;0"
}
}
],
"objectsFolderStructure": {
@@ -2220,9 +2045,6 @@
{
"objectName": "NewText6"
},
{
"objectName": "NewText7"
},
{
"objectName": "FlagBlue"
},
@@ -2295,23 +2117,6 @@
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [],
"actions": [
{
"type": {
"value": "SaveState::SetVariableSaveConfiguration"
},
"parameters": [
"",
"VariableExcludedFromSave",
"no",
""
]
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
@@ -2348,12 +2153,11 @@
},
{
"type": {
"value": "SaveState::CreateGameSaveStateInStorage"
"value": "SaveState::SaveGameSnapshotToStorage"
},
"parameters": [
"",
"\"test\"",
""
"\"test\""
]
}
]
@@ -2383,143 +2187,10 @@
"actions": [
{
"type": {
"value": "SaveState::RestoreGameSaveStateFromStorage"
"value": "SaveState::LoadGameSnapshotFromStorage"
},
"parameters": [
"",
"\"test\"",
"",
"no"
]
}
]
},
{
"type": "BuiltinCommonInstructions::Comment",
"color": {
"b": 109,
"g": 230,
"r": 255,
"textB": 0,
"textG": 0,
"textR": 0
},
"comment": "Press s to save and l to load manually. Check how the \"VariableExcludedFromSave\" won't be impacted."
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
{
"type": {
"value": "KeyFromTextJustPressed"
},
"parameters": [
"",
"\"s\""
]
}
],
"actions": [
{
"type": {
"value": "SaveState::CreateGameSaveStateInStorage"
},
"parameters": [
"",
"\"manual-save\"",
""
]
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
{
"type": {
"value": "KeyFromTextJustPressed"
},
"parameters": [
"",
"\"l\""
]
}
],
"actions": [
{
"type": {
"value": "SaveState::RestoreGameSaveStateFromStorage"
},
"parameters": [
"",
"\"manual-save\"",
"",
""
]
}
]
},
{
"type": "BuiltinCommonInstructions::Comment",
"color": {
"b": 109,
"g": 230,
"r": 255,
"textB": 0,
"textG": 0,
"textR": 0
},
"comment": "Press c to just save the coins and press x to load only the coins. Check how the rest won't be impacted."
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
{
"type": {
"value": "KeyFromTextJustPressed"
},
"parameters": [
"",
"\"c\""
]
}
],
"actions": [
{
"type": {
"value": "SaveState::CreateGameSaveStateInStorage"
},
"parameters": [
"",
"\"coins-save\"",
"\"coins\""
]
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
{
"type": {
"value": "KeyFromTextJustPressed"
},
"parameters": [
"",
"\"x\""
]
}
],
"actions": [
{
"type": {
"value": "SaveState::RestoreGameSaveStateFromStorage"
},
"parameters": [
"",
"\"coins-save\"",
"\"coins\"",
""
"\"test\""
]
}
]
@@ -2561,7 +2232,6 @@
},
"parameters": [
">=",
">",
"0"
]
}
@@ -2588,7 +2258,6 @@
"value": "SaveState::TimeSinceLastSave"
},
"parameters": [
"<",
"<",
"0"
]
@@ -2617,7 +2286,6 @@
},
"parameters": [
">=",
">",
"0"
]
}
@@ -2644,7 +2312,6 @@
"value": "SaveState::TimeSinceLastLoad"
},
"parameters": [
"<",
"<",
"0"
]
@@ -3041,84 +2708,6 @@
]
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [],
"actions": [
{
"type": {
"value": "TextContainerCapability::TextContainerBehavior::SetValue"
},
"parameters": [
"NewText7",
"Text",
"=",
"\"Excluded variable: \" + VariableExcludedFromSave"
]
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
{
"type": {
"value": "DepartScene"
},
"parameters": [
""
]
}
],
"actions": [
{
"type": {
"value": "ResetTimer"
},
"parameters": [
"",
"\"ExcludedVariableIncreaseTimer\""
]
}
]
},
{
"type": "BuiltinCommonInstructions::Standard",
"conditions": [
{
"type": {
"value": "CompareTimer"
},
"parameters": [
"",
"\"ExcludedVariableIncreaseTimer\"",
">=",
"0.5"
]
}
],
"actions": [
{
"type": {
"value": "ResetTimer"
},
"parameters": [
"",
"\"ExcludedVariableIncreaseTimer\""
]
},
{
"type": {
"value": "SetNumberVariable"
},
"parameters": [
"VariableExcludedFromSave",
"+",
"1"
]
}
]
}
],
"layers": [
@@ -3230,10 +2819,6 @@
"name": "Resizable",
"type": "ResizableCapability::ResizableBehavior"
},
{
"name": "SaveConfiguration",
"type": "SaveState::SaveConfiguration"
},
{
"name": "Scale",
"type": "ScalableCapability::ScalableBehavior"
@@ -13765,7 +13350,6 @@
}
],
"instances": [],
"editionSettings": {},
"eventsFunctions": [
{
"fullName": "",

View File

@@ -154,8 +154,6 @@ module.exports = function (config) {
'./newIDE/app/resources/GDJS/Runtime/Extensions/Spine/spineruntimeobject.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Spine/spineruntimeobject-pixi-renderer.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/Spine/managers/*.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/SaveState/SaveStateTools.js',
'./newIDE/app/resources/GDJS/Runtime/Extensions/SaveState/SaveConfigurationRuntimeBehavior.js',
// Test extensions:
'./GDJS/tests/tests/Extensions/**.js',

View File

@@ -3251,8 +3251,6 @@ interface EventsBasedObjectVariant {
void SetAssetStoreOriginalName([Const] DOMString assetStoreOriginalName);
[Const, Ref] DOMString GetAssetStoreOriginalName();
[Ref] EditorSettings GetAssociatedEditorSettings();
void SerializeTo([Ref] SerializerElement element);
void UnserializeFrom([Ref] Project project, [Const, Ref] SerializerElement element);
};

View File

@@ -2363,7 +2363,6 @@ export class EventsBasedObjectVariant extends EmscriptenObject {
getAssetStoreAssetId(): string;
setAssetStoreOriginalName(assetStoreOriginalName: string): void;
getAssetStoreOriginalName(): string;
getAssociatedEditorSettings(): EditorSettings;
serializeTo(element: SerializerElement): void;
unserializeFrom(project: Project, element: SerializerElement): void;
}

View File

@@ -22,7 +22,6 @@ declare class gdEventsBasedObjectVariant {
getAssetStoreAssetId(): string;
setAssetStoreOriginalName(assetStoreOriginalName: string): void;
getAssetStoreOriginalName(): string;
getAssociatedEditorSettings(): gdEditorSettings;
serializeTo(element: gdSerializerElement): void;
unserializeFrom(project: gdProject, element: gdSerializerElement): void;
delete(): void;

View File

@@ -35,7 +35,7 @@ GDevelop is composed of an **editor**, a **game engine**, an **ecosystem** of ex
| `Core` | Core classes, describing the structure of a game and tools to implement the IDE and work with GDevelop games. |
| `GDJS` | The game engine, written in TypeScript, using PixiJS and Three.js for 2D and 3D rendering (WebGL), powering all GDevelop games. |
| `GDevelop.js` | Bindings of `Core`, `GDJS` and `Extensions` to JavaScript (with WebAssembly), used by the IDE. |
| `newIDE` | The game editor, written in JavaScript with React, Electron, PixiJS and Three.js. |
| `newIDE` | The game editor, written in JavaScript with React, Electron, PixiJS and Three.js.js. |
| `Extensions` | Built-in extensions for the game engine, providing objects, behaviors and new features. For example, this includes the physics engines running in WebAssembly (Box2D or Jolt Physics for 3D). All the [official and experimental extensions are on this repository](https://github.com/GDevelopApp/GDevelop-extensions). [Community extensions are available here](https://github.com/GDevelopApp/GDevelop-community-list). |
To learn more about GDevelop Architecture, read the [architecture overview here](Core/GDevelop-Architecture-Overview.md).

View File

@@ -1,3 +1,3 @@
This library's sources are used by the [tile map extension](../../Extensions/TileMap/).
This library sources is used by the [tile map extension](../../Extensions/TileMap/).
The `npm run build` command copies the bundled library to the right place for the extension to use it.
The `npm run build` command copy the bundled library at the right place for the extension to use it.

View File

@@ -16,9 +16,9 @@ the `build-theme-resources` script in the scripts folder. There is also an npm s
## Creating new themes
Themes are stored in [this Theme Folder](./app/src/UI/Theme). Each theme has its own subfolder containing:
Themes are stored in [this Theme Folder](./app/src/UI/Theme). Each theme has its own subfolder containing
- A JavaScript file (`index.js`) to generate the object describing the theme that is used by the editor (class names, values to apply to Material-UI) and that also imports the CSS file.
- A javascript file (`index.js`) to generate the object describing the theme that is used by the editor (class names, values to apply to Material-UI) and that also imports the css file.
- The `theme.json` file which stores the theme details (colors, widths, etc.)
To create the necessary files, run the following command:

View File

@@ -95,7 +95,7 @@ Any text editor is fine, but it's a good idea to have one with _Prettier_ (code
Cloud storage providers are set up with development keys when you're running GDevelop in development mode. For these, to work, you must execute the web-app not from the traditional `http://localhost:3000` origin, but from `http://gdevelop-app-local.com:3000`:
- Set up a [redirection in your hosts file](https://www.howtogeek.com/howto/27350/beginner-geek-how-to-edit-your-hosts-file/), that should look like: `127.0.0.1 gdevelop-app-local.com`.
- Launch the web app from `http://gdevelop-app-local.com:3000`.
- Launch then the web app from `http://gdevelop-app-local:3000`.
> This is only necessary if you want to have cloud storage providers working in development. If not done, GDevelop will simply display an error while trying to use them.

View File

@@ -1,6 +1,5 @@
# Generated files
src/Version/VersionMetadata.js
public/service-worker.js
# dependencies
node_modules

View File

@@ -33,7 +33,7 @@
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "1.275.2",
"posthog-js": "^1.57.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
"react": "16.14.0",
@@ -5770,11 +5770,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@posthog/core": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.3.0.tgz",
"integrity": "sha512-hxLL8kZNHH098geedcxCz8y6xojkNYbmJEW+1vFXsmPcExyCXIUUJ/34X6xa9GcprKxd0Wsx3vfJQLQX4iVPhw=="
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"license": "BSD-3-Clause"
@@ -25651,43 +25646,16 @@
}
},
"node_modules/posthog-js": {
"version": "1.275.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.275.2.tgz",
"integrity": "sha512-g1fnV/GAcEdwwk4EVbJ1HMZhlhgKYxG1Z5KPGvr+q5re0ltyVq8jFA2PsF333jvOlI8R01LLdpYSIgU8sBiZfg==",
"version": "1.57.2",
"license": "MIT",
"dependencies": {
"@posthog/core": "1.3.0",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/posthog-js/node_modules/core-js": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
"fflate": "^0.4.1",
"rrweb-snapshot": "^1.1.14"
}
},
"node_modules/preact": {
"version": "10.27.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"version": "10.13.1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -30123,6 +30091,10 @@
"node": ">= 10.13.0"
}
},
"node_modules/rrweb-snapshot": {
"version": "1.1.14",
"license": "MIT"
},
"node_modules/rtl-css-js": {
"version": "1.14.0",
"license": "MIT",
@@ -33134,11 +33106,6 @@
"defaults": "^1.0.3"
}
},
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
"dev": true,

View File

@@ -69,7 +69,7 @@
"path-browserify": "^1.0.1",
"pixi-spine": "4.0.4",
"pixi.js-legacy": "7.4.2",
"posthog-js": "1.275.2",
"posthog-js": "^1.57.2",
"prop-types": "^15.5.10",
"qr-creator": "^1.0.0",
"react": "16.14.0",
@@ -120,7 +120,7 @@
"import-resources": "npm run import-zipped-external-editors && npm run build-theme-resources && cd scripts && node import-libGD.js && node import-GDJS-Runtime.js && node import-monaco-editor.js && node import-zipped-external-libs.js",
"make-version-metadata": "cd scripts && node make-version-metadata.js",
"make-service-worker": "cd scripts && node make-service-worker.js",
"start": "npm run import-resources && npm run make-version-metadata && concurrently \"react-app-rewired start\" \"node scripts/watch-serve-GDJS-runtime.js\" -n \"editor,game engine\" --kill-others",
"start": "npm run import-resources && npm run make-version-metadata && concurrently \"react-app-rewired start\" \"node scripts/watch-serve-GDJS-runtime.js\" -n \"editor,game engine\"",
"electron-app": "cd ../electron-app && npm run start",
"build": "npm run import-resources && npm run make-version-metadata && react-app-rewired build && npm run make-service-worker",
"format": "prettier --write \"src/!(locales)/**/*.js\"",

View File

@@ -33,4 +33,4 @@ Latest versions of `react-sortable-hoc` seems to be breaking the lists. The exac
- `react-mosaic-component` is a custom version where `react-dnd` was simply upgraded to version `7.7.0`
- `@lingui/react` is a version where Flow definitions have been fixed.
- `pixi-simple-gesture` is a version where an extra check for `undefined` has been added to `touchStart` in `pan.js`, following traces of errors that have been inspected (though the bug could not be reproduced - but better be safe).
- `pixi-simple-gesture` is a version where an extra check for `undefined` have been added to `touchStart` in `pan.js`, following traces of errors that have been inspected (though the bug could not be reproduced - but better be safe).

View File

@@ -12,7 +12,6 @@ const {
getHelpLink,
getExtensionFolderName,
improperlyFormattedHelpPaths,
generateSvgImageIcon,
} = require('./lib/WikiHelpLink');
const {
convertCommonMarkdownToPythonMarkdown,
@@ -24,8 +23,6 @@ const {
rawTextsToString,
} = require('./lib/ExtensionReferenceGenerator');
const { mapVector, mapFor } = require('./lib/MapFor');
const { groupBy, sortKeys } = require('./lib/ArrayHelpers');
const { generateAllExtensionsSections } = require('./lib/WikiExtensionTable');
/** @typedef {import("./lib/ExtensionReferenceGenerator.js").RawText} RawText */
@@ -38,6 +35,10 @@ const outputRootPath = path.join(gdRootPath, 'docs-wiki');
const extensionsRootPath = path.join(outputRootPath, 'extensions');
const extensionsMainFilePath = path.join(extensionsRootPath, 'index.md');
const generateSvgImageIcon = iconUrl => {
return `<img src="${iconUrl}" class="extension-icon"></img>`;
};
/**
* @param {{id: string, username: string}[]} authors
*/
@@ -145,6 +146,28 @@ const getAllExtensionShortHeaders = async () => {
return extensionShortHeaders;
};
const groupBy = (array, getKey) => {
const table = {};
for (const element of array) {
const key = getKey(element);
let group = table[key];
if (!group) {
group = [];
table[key] = group;
}
group.push(element);
}
return table;
};
const sortKeys = table => {
const sortedTable = {};
for (const key of Object.keys(table).sort()) {
sortedTable[key] = table[key];
}
return sortedTable;
};
/**
* Create a page for an extension.
* @param {any} gd
@@ -316,6 +339,46 @@ const generateEventsFunctionExtensionMetadata = (
return extension;
};
/**
* Generate a section for an extension.
* @param {any} extension The extension (gdEventsFunctionsExtension)
*/
const generateExtensionSection = extension => {
const folderName = getExtensionFolderName(extension.getName());
const referencePageUrl = `${gdevelopWikiUrlRoot}/extensions/${folderName}`;
const helpPageUrl = getHelpLink(extension.getHelpPath()) || referencePageUrl;
return `|${generateSvgImageIcon(
extension.getPreviewIconUrl()
)}|**${extension.getFullName()}**|${extension.getShortDescription()}|${`[Read more...](${helpPageUrl})` +
(helpPageUrl !== referencePageUrl
? ` ([reference](${referencePageUrl}))`
: '')}|\n`;
};
/**
* @param {Array<any>} extensions The extension (gdEventsFunctionsExtension)
*/
const generateAllExtensionsSections = extensions => {
let extensionSectionsContent = '';
const extensionsByCategory = sortKeys(
groupBy(extensions, pair => pair.getCategory() || 'General')
);
for (const category in extensionsByCategory) {
const extensions = extensionsByCategory[category];
extensionSectionsContent += `### ${category}\n\n`;
extensionSectionsContent += '||Name|Description||\n';
extensionSectionsContent += '|---|---|---|---|\n';
for (const extension of extensions) {
extensionSectionsContent += generateExtensionSection(extension);
}
extensionSectionsContent += '\n';
}
return extensionSectionsContent;
};
/**
* @param {Array<any>} extensions The extension (gdEventsFunctionsExtension)
*/
@@ -354,14 +417,14 @@ const generateExtensionsMkDocsDotPagesFile = async (
- index.md
- search.md
- tiers.md
${generateExtensionsPageList(reviewedExtensions, 1)}
- Experimental extensions:
${generateExtensionsPageList(experimentalExtensions, 2)}
- ...
- Create your own extensions:
- Create a new extension : create.md
- best-practices.md
- share-extension.md
${generateExtensionsPageList(reviewedExtensions, 1)}
- Experimental extensions:
${generateExtensionsPageList(experimentalExtensions, 2)}
- ...
`;
const extensionsDotPagesFilePath = path.join(extensionsRootPath, '.pages');
@@ -370,7 +433,14 @@ ${generateExtensionsPageList(experimentalExtensions, 2)}
};
const generateExtensionsList = async gd => {
let content = '';
let content = `## Extensions list
Here are listed all the extensions available in GDevelop. The list is divided in [two tiers](/gdevelop5/extensions/tiers/):
- [Reviewed extensions](#reviewed-extensions)
- [Experimental extensions](#experimental-extensions)
`;
const project = new gd.ProjectHelper.createNewGDJSProject();
await addAllExtensionsToProject(gd, project);
const extensionShortHeaders = await getAllExtensionShortHeaders();
@@ -407,10 +477,7 @@ const generateExtensionsList = async gd => {
false
);
}
content += generateAllExtensionsSections({
extensions: reviewedExtensions,
baseFolder: 'extensions',
});
content += generateAllExtensionsSections(reviewedExtensions);
content += `## Experimental extensions
@@ -436,10 +503,7 @@ guarantee they meet all the quality standards of fully reviewed extensions.
true
);
}
content += generateAllExtensionsSections({
extensions: experimentalExtensions,
baseFolder: 'extensions',
});
content += generateAllExtensionsSections(experimentalExtensions);
await generateExtensionsMkDocsDotPagesFile(
reviewedExtensions,
@@ -454,16 +518,11 @@ initializeGDevelopJs().then(async gd => {
try {
console.info(` Loading all community extensions...`);
let indexPageContent = `---
icon: material/star-plus
---
# Extensions
let indexPageContent = `# Extensions
In addition to [core features](/gdevelop5/all-features), new capabilities are provided by extensions. Extensions can contain objects, behaviors, actions, conditions, expressions, effects or events.
Official as well as experimental extensions are accessible [directly from GDevelop](/gdevelop5/extensions/search).
A list of [community-made extensions is available on GitHub](https://github.com/GDevelopApp/GDevelop-community-list).
GDevelop is built in a flexible way. In addition to [core features](/gdevelop5/all-features), new capabilities are provided by extensions. Extensions can contain objects, behaviors, actions, conditions, expressions or events.
Community created extensions are accessible [directly from GDevelop](/gdevelop5/extensions/search).
New extensions can also be [created](/gdevelop5/extensions/create) from scratch using events or JavaScript.
Read more about this:

View File

@@ -9,12 +9,12 @@ const fs = require('fs').promises;
const path = require('path');
const shell = require('shelljs');
const {
gdevelopWikiUrlRoot,
getHelpLink,
generateReadMoreLink,
improperlyFormattedHelpPaths,
getExtensionFolderName,
} = require('./lib/WikiHelpLink');
const { groupBy, sortKeys } = require('./lib/ArrayHelpers');
const { generateAllExtensionsSections } = require('./lib/WikiExtensionTable');
shell.exec('node import-GDJS-Runtime.js');
@@ -27,7 +27,6 @@ const expressionsFilePath = path.join(
allFeaturesRootPath,
'expressions-reference.md'
);
const {
generateExtensionReference,
generateExtensionRawText,
@@ -94,42 +93,55 @@ const generateAllExtensionReferences = gd => {
*/
const generateAllFeaturesStartPageRawTexts = extensionReferences => {
const headerText = {
text: `---
icon: material/star
---
# All GDevelop core features
text: `# All features
This page lists **all the core features** that are provided in GDevelop. These can be objects, visual effects, behaviors, actions, conditions or expressions.
This page lists **all the features** that are provided in GDevelop. These can be objects, behaviors but also features that can be used directly using actions, conditions or expressions (without requiring an object to be existing on the scene).
GDevelop can also be extended with extensions: take a look at [the list of extended features](/gdevelop5/extensions) or learn how to create your [own extensions](/gdevelop5/extensions/create).
Note that GDevelop can also be extended with extensions: take a look at [the list of community extensions](/gdevelop5/extensions) or learn how to create your [own set of features (behaviors, actions, conditions or expressions)](/gdevelop5/extensions/create).
`,
};
const footerText = {
text: `
You can also find a **reference sheet of all base expressions**:
You can also find a **reference sheet of all expressions**:
* [Expressions reference](/gdevelop5/all-features/expressions-reference)
## More features as extensions
You can also [search for new features in list of extensions](/gdevelop5/extensions), or create your [own objects, behaviors, actions, conditions or expressions](/gdevelop5/extensions/create).`,
Remember that you can also [search for new features in the community extensions](/gdevelop5/extensions), or create your [own set of features (behaviors, actions, conditions or expressions)](/gdevelop5/extensions/create).`,
};
const filteredExtensions = extensionReferences
.filter(extensionReference => {
return !ignoredExtensionNames.includes(
extensionReference.extension.getName()
);
})
.map(extensionReference => extensionReference.extension);
return [
headerText,
...extensionReferences
.filter(extensionReference => {
return !ignoredExtensionNames.includes(
extensionReference.extension.getName()
);
})
.flatMap(extensionReferences => {
const folderName = getExtensionFolderName(
extensionReferences.extension.getName()
);
const helpPagePath = extensionReferences.extension.getHelpPath();
const referencePageUrl = `${gdevelopWikiUrlRoot}/all-features/${folderName}/reference`;
const helpPageUrl = getHelpLink(helpPagePath) || referencePageUrl;
const groupedContent = generateAllExtensionsSections({
extensions: filteredExtensions,
baseFolder: 'all-features',
});
return [headerText, { text: groupedContent }, footerText];
return [
{
text:
'* ' +
// Link to help page or to reference if none.
`[${extensionReferences.extension.getFullName()}](${helpPageUrl})` +
(helpPageUrl !== referencePageUrl
? ` ([reference](${referencePageUrl}))`
: ''),
},
];
}),
footerText,
];
};
/** @returns {RawText} */
@@ -190,61 +202,6 @@ const generateExtensionRawTexts = extensionReferences => {
return { allExtensionRawTexts };
};
/**
* Generate the .pages nav list for All features, grouped by category.
* @param {Array<ExtensionReference>} extensionReferences
* @param {number} indentationLevel
*/
const generateAllFeaturesPageList = (extensionReferences, indentationLevel) => {
const filteredExtensions = extensionReferences.filter(extensionReference => {
return !ignoredExtensionNames.includes(
extensionReference.extension.getName()
);
});
const extensionsByCategory = sortKeys(
groupBy(filteredExtensions, ref => ref.extension.getCategory() || 'General')
);
const baseIndentation = ' '.repeat(4 * indentationLevel);
let pagesList = '';
for (const category in extensionsByCategory) {
pagesList += `${baseIndentation}- ${category}:\n`;
const extensionReferences = extensionsByCategory[category]
.slice()
.sort((a, b) =>
a.extension.getFullName().localeCompare(b.extension.getFullName())
);
for (const { extension } of extensionReferences) {
const folderName = getExtensionFolderName(extension.getName());
pagesList += `${baseIndentation} - ${extension.getFullName()}: ${folderName}\n`;
}
}
return pagesList.length === 0
? pagesList
: pagesList.substring(0, pagesList.length - 1);
};
/**
* Write the .pages file for All features
* @param {Array<ExtensionReference>} extensionReferences
*/
const generateAllFeaturesMkDocsDotPagesFile = async extensionReferences => {
const dotPagesContent = `nav:
- index.md
${generateAllFeaturesPageList(extensionReferences, 1)}
- ...
- expressions-reference.md
`;
const allFeaturesDotPagesFilePath = path.join(allFeaturesRootPath, '.pages');
await fs.writeFile(allFeaturesDotPagesFilePath, dotPagesContent);
console.info(` File generated: ${allFeaturesDotPagesFilePath}`);
};
/**
* @param {Array<ExtensionReference>} extensionReferences
* @returns {Array<RawText>}
@@ -380,9 +337,6 @@ initializeGDevelopJs().then(async gd => {
);
console.info(` File generated: ${allFeaturesFilePath}`);
// Generate .pages to organize navigation by categories
await generateAllFeaturesMkDocsDotPagesFile(extensionReferences);
if (improperlyFormattedHelpPaths.size > 0) {
console.info(
`⚠️ Reference documents generated, but some help paths are invalid:`,

View File

@@ -1,41 +0,0 @@
// @ts-check
/**
* @template T
* @param {Array<T>} array
* @param {(T) => string} getKey
* @returns {Record<string, Array<T>>}
*/
const groupBy = (array, getKey) => {
/** @type {Record<string, Array<T>>} */
const table = {};
for (const element of array) {
const key = getKey(element);
let group = table[key];
if (!group) {
group = [];
table[key] = group;
}
group.push(element);
}
return table;
};
/**
* @template T
* @param {Record<string, Array<T>>} table
* @returns {Record<string, Array<T>>}
*/
const sortKeys = table => {
/** @type {Record<string, Array<T>>} */
const sortedTable = {};
for (const key of Object.keys(table).sort()) {
sortedTable[key] = table[key];
}
return sortedTable;
};
module.exports = {
groupBy,
sortKeys,
};

View File

@@ -9,7 +9,6 @@ const { generateReadMoreLink } = require('./WikiHelpLink');
/** @typedef {import('../../../../GDevelop.js/types').ExpressionMetadata} ExpressionMetadata */
/** @typedef {import('../../../../GDevelop.js/types').ObjectMetadata} ObjectMetadata */
/** @typedef {import('../../../../GDevelop.js/types').BehaviorMetadata} BehaviorMetadata */
/** @typedef {import('../../../../GDevelop.js/types').ParameterMetadata} ParameterMetadata */
/**
* @typedef {Object} RawText A text to be shown on a page
@@ -171,7 +170,6 @@ const translateTypeToHumanReadableDescription = type => {
if (type === 'expression') return '🔢 Number';
if (type === 'camera') return '🔢 Camera index (Number)';
if (type === 'object') return '👾 Object';
if (type === 'objectList') return '👾 Object';
if (type === 'objectPtr') return '👾 Object';
if (type === 'objectListOrEmptyIfJustDeclared') return '👾 Object';
@@ -202,12 +200,6 @@ const translateTypeToHumanReadableDescription = type => {
if (type === 'externalLayoutName') return '🔤 External Layout Name (String)';
if (type === 'leaderboardId') return '🔤 Leaderboard Identifier (String)';
if (type === 'operator') return '🟰 Operator';
if (type === 'relationalOperator') return '🟰 Relational operator';
if (type === 'yesorno') return '❓ Yes or No';
if (type === 'trueorfalse') return '❓ True or False';
return type;
};
@@ -243,101 +235,9 @@ const translateTypeToHumanReadableType = type => {
if (type === 'externalLayoutName') return 'external layout name';
if (type === 'leaderboardId') return 'leaderboard identifier';
if (type === 'yesorno') return 'yes or no';
if (type === 'trueorfalse') return 'true or false';
return type;
};
/**
* @param {string} type
* @param {string} sanitizedDescription
*/
const isDescriptionObvious = (type, sanitizedDescription) => {
const isDescriptionSameAsType =
sanitizedDescription.toLowerCase().replace(/\s+/g, '') ===
type.toLowerCase();
if (isDescriptionSameAsType) return true;
if (
type === 'number' &&
(sanitizedDescription === 'Expression' ||
sanitizedDescription === 'Expression (number)' ||
sanitizedDescription === 'Expression.')
)
return true;
if (type === 'operator' || type === 'relationalOperator') {
return true;
}
if (
type === 'layerEffectName' ||
type === 'layerEffectParameterName' ||
type === 'objectEffectName' ||
type === 'objectEffectParameterName' ||
type === 'objectPointName' ||
type === 'objectAnimationName' ||
type === 'externalLayoutName'
) {
return true;
}
return false;
};
const normalizeType = (/** @type {string} */ parameterType) => {
if (parameterType === 'expression') return 'number';
if (
parameterType === 'object' ||
parameterType === 'objectPtr' ||
parameterType === 'objectList' ||
parameterType === 'objectListOrEmptyIfJustDeclared' ||
parameterType === 'objectListOrEmptyWithoutPicking'
) {
return 'object';
}
return parameterType;
};
/**
* @param {string} type
* @param {string} sanitizedDescription
*/
const getSimplifiedParameterDescription = (type, sanitizedDescription) => {
if (
type === 'number' &&
sanitizedDescription.toLowerCase().includes('camera number')
) {
return 'Camera number';
}
return null;
};
/**
* @param {ParameterMetadata} parameterMetadata
* @returns {string}
*/
const getParameterExtraInfoDescription = parameterMetadata => {
if (parameterMetadata.getType() === 'stringWithSelector') {
const rawExtraInfo = parameterMetadata.getExtraInfo();
try {
const parsedExtraInfo = JSON.parse(rawExtraInfo);
if (Array.isArray(parsedExtraInfo)) {
return `(one of: ${parsedExtraInfo
.map(value => `"${value}"`)
.join(', ')})`;
}
} catch (err) {
return `(value must be: ${rawExtraInfo})`;
}
}
return '';
};
/** @returns {ReferenceText} */
const generateInstructionReferenceRowsText = ({
instructionType,
@@ -346,79 +246,15 @@ const generateInstructionReferenceRowsText = ({
objectMetadata,
behaviorMetadata,
}) => {
const paramPadding = ' ';
const codeOnlyParametersIndexes = [];
let parametersList = mapFor(
0,
instructionMetadata.getParameters().getParametersCount(),
index => {
const parameterMetadata = instructionMetadata
.getParameters()
.getParameterAt(index);
const longDescription = parameterMetadata.getLongDescription();
const sanitizedDescription = [
parameterMetadata.getDescription(),
longDescription,
]
.filter(Boolean)
.join('\n')
.replace(/\n/g, `\n${paramPadding} `);
const type = normalizeType(parameterMetadata.getType());
const humanReadableTypeDesc = translateTypeToHumanReadableDescription(
type
);
if (parameterMetadata.isCodeOnly()) {
codeOnlyParametersIndexes.push(index);
return null;
}
const simplifiedParameterDescription = getSimplifiedParameterDescription(
type,
sanitizedDescription
);
const extraInfoDescription = getParameterExtraInfoDescription(
parameterMetadata
);
return [
simplifiedParameterDescription
? `${paramPadding}- Parameter ${index} (${humanReadableTypeDesc}): ${simplifiedParameterDescription}`
: isDescriptionObvious(type, sanitizedDescription)
? `${paramPadding}- Parameter ${index}: ${humanReadableTypeDesc}`
: `${paramPadding}- Parameter ${index} (${humanReadableTypeDesc}): ${sanitizedDescription}`,
extraInfoDescription,
]
.filter(Boolean)
.join(' ');
}
)
.filter(Boolean)
.join('\n');
if (codeOnlyParametersIndexes.length) {
parametersList +=
'\n\n' +
`${paramPadding}> Technical note: ${
codeOnlyParametersIndexes.length === 1 ? 'parameter' : 'parameters'
} ${codeOnlyParametersIndexes.join(
', '
)} are internal parameters handled by GDevelop.`;
}
return {
orderKey: instructionType,
text: [
'**' + instructionMetadata.getFullName() + '** ',
instructionMetadata.getDescription().replace(/\n/, ` \n`),
'',
...(parametersList
? ['??? quote "See parameters"', '', parametersList, '']
: []),
].join('\n'),
text:
'**' +
instructionMetadata.getFullName() +
'** ' +
'\n' +
instructionMetadata.getDescription().replace(/\n/, ' \n') +
'\n',
};
};

View File

@@ -1,72 +0,0 @@
// @ts-check
const { sortKeys, groupBy } = require('./ArrayHelpers');
const {
gdevelopWikiUrlRoot,
generateSvgImageIcon,
getExtensionFolderName,
getHelpLink,
} = require('./WikiHelpLink');
/** @typedef {import('../../../../GDevelop.js/types').EventsFunctionsExtension} EventsFunctionsExtension */
/** @typedef {import('../../../../GDevelop.js/types').PlatformExtension} PlatformExtension */
/**
* Generate a section for an extension.
* @param {EventsFunctionsExtension | PlatformExtension} extension The extension
* @param {string} baseFolder The base folder for the extension pages.
*/
const generateExtensionSection = (extension, baseFolder) => {
const folderName = getExtensionFolderName(extension.getName());
const referencePageUrl = `${gdevelopWikiUrlRoot}/${baseFolder}/${folderName}`;
const helpPageUrl = getHelpLink(extension.getHelpPath()) || referencePageUrl;
// @ts-ignore
const icon = extension.getPreviewIconUrl
? // @ts-ignore
extension.getPreviewIconUrl()
: extension.getIconUrl();
// @ts-ignore
const shortDescription = extension.getShortDescription
? // @ts-ignore
extension.getShortDescription()
: extension.getDescription().slice(0, 100) + '...';
return `|${generateSvgImageIcon(
icon
)}|**${extension.getFullName()}**|${shortDescription}|${`[Read more...](${helpPageUrl})` +
(helpPageUrl !== referencePageUrl
? ` ([reference](${referencePageUrl}))`
: '')}|\n`;
};
/**
* @param {{extensions: Array<EventsFunctionsExtension | PlatformExtension>, baseFolder: string}} options
*/
const generateAllExtensionsSections = ({ extensions, baseFolder }) => {
let extensionSectionsContent = '';
/** @type {Record<string, Array<EventsFunctionsExtension | PlatformExtension>>} */
const extensionsByCategory = sortKeys(
groupBy(extensions, pair => pair.getCategory() || 'General')
);
for (const category in extensionsByCategory) {
const extensions = extensionsByCategory[category];
extensionSectionsContent += `### ${category}\n\n`;
extensionSectionsContent += '||Name|Description||\n';
extensionSectionsContent += '|---|---|---|---|\n';
for (const extension of extensions) {
extensionSectionsContent += generateExtensionSection(
extension,
baseFolder
);
}
extensionSectionsContent += '\n';
}
return extensionSectionsContent;
};
module.exports = {
generateAllExtensionsSections,
};

View File

@@ -67,15 +67,10 @@ const getExtensionFolderName = extensionName => {
);
};
const generateSvgImageIcon = iconUrl => {
return `<img src="${iconUrl}" class="extension-icon"></img>`;
};
module.exports = {
gdevelopWikiUrlRoot,
improperlyFormattedHelpPaths,
getHelpLink,
generateReadMoreLink,
getExtensionFolderName,
generateSvgImageIcon,
};

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const shell = require('shelljs');
const workboxBuild = require('workbox-build');
const buildPath = '../public';
const buildPath = '../build';
const VersionMetadata = require('../src/Version/VersionMetadata');
/**
@@ -46,7 +46,7 @@ const buildSW = () => {
return workboxBuild
.injectManifest({
swSrc: 'service-worker-template/service-worker-template.js',
swDest: '../public/service-worker.js',
swDest: '../build/service-worker.js',
globDirectory: buildPath,
globPatterns: [
// Application:
@@ -85,13 +85,13 @@ const buildSW = () => {
.then(({ count, size, warnings }) => {
if (
!replaceInFile(
'../public/service-worker.js',
'../build/service-worker.js',
'VersionMetadata = {}',
'VersionMetadata = ' + JSON.stringify(VersionMetadata)
)
) {
console.error(
'Error while trying to replace version metadata in public/service-worker.js.'
'Error while trying to replace version metadata in build/service-worker.js.'
);
shell.exit(1);
}

View File

@@ -1,249 +1,64 @@
/* eslint-disable no-restricted-globals */
if (typeof importScripts === 'function') {
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
);
/* global workbox */
if (workbox) {
// Will be replaced by make-service-worker.js to include the proper version.
const VersionMetadata = {};
// ============================================================================
// Browser Service Worker powered Preview ("Browser SW Preview"), using IndexedDB.
// ============================================================================
// Contrary to other static assets (JS, CSS, HTML), libGD.js/wasm are not
// versioned in their filenames. Instead, we version using a query string
// (see src/index.js where it's loaded with the same query string).
workbox.precaching.precacheAndRoute([
{
url: `libGD.js?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
{
url: `libGD.wasm?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
]);
console.log('[ServiceWorker] Service worker file executed');
/* injection point for manifest files. */
workbox.precaching.precacheAndRoute([]);
const swURL = new URL(self.location.href);
const isDev = swURL.searchParams.has('dev');
// If updated, also update the BrowserSWIndexedDB module.
const DB_NAME = 'gdevelop-browser-sw-preview';
const STORE_NAME = 'files';
const DB_VERSION = 1;
/**
* Opens the IndexedDB database for browser SW preview files.
*/
function openBrowserSWPreviewDB() {
return new Promise((resolve, reject) => {
try {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
const error = request.error || new Error('Failed to open IndexedDB');
console.error('[ServiceWorker] Error opening preview database:', error);
reject(error);
};
request.onsuccess = () => {
const db = request.result;
console.log('[ServiceWorker] Preview database opened successfully');
resolve(db);
};
request.onupgradeneeded = () => {
console.log('[ServiceWorker] Upgrading preview database schema...');
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
console.log('[ServiceWorker] Created object store:', STORE_NAME);
}
};
} catch (error) {
console.error('[ServiceWorker] Exception while opening preview database:', error);
reject(error);
}
});
}
/**
* Retrieves a preview file from IndexedDB.
*/
async function getBrowserSWPreviewFile(path) {
try {
const db = await openBrowserSWPreviewDB();
return new Promise((resolve, reject) => {
let settled = false;
const safeResolve = (v) => { if (!settled) { settled = true; resolve(v); } };
const safeReject = (e) => { if (!settled) { settled = true; reject(e); } };
try {
// Sanity-check the store exists (avoids InvalidStateError).
if (!db.objectStoreNames.contains(STORE_NAME)) {
const err = new Error(`Object store "${STORE_NAME}" not found`);
console.error('[ServiceWorker] Missing object store while getting file:', path, err);
return safeReject(err);
}
const tx = db.transaction(STORE_NAME, 'readonly');
// If the transaction aborts (quota, deadlock, explicit abort, etc.), reject.
tx.onabort = () => {
const error = tx.error || new Error('Transaction aborted');
console.error('[ServiceWorker] Transaction aborted while getting file:', path, error);
safeReject(error);
};
// `onerror` at the transaction level can fire even if request handlers didnt.
tx.onerror = () => {
const error = tx.error || new Error('Transaction failed');
console.error('[ServiceWorker] Transaction error while getting file:', path, error);
safeReject(error);
};
const store = tx.objectStore(STORE_NAME);
const req = store.get(path);
req.onsuccess = () => {
const result = req.result;
safeResolve(result || null);
};
req.onerror = () => {
const error = req.error || new Error('Get operation failed');
console.error('[ServiceWorker] Error retrieving file from IndexedDB:', path, error);
safeReject(error);
};
} catch (error) {
console.error('[ServiceWorker] Exception during get operation:', path, error);
safeReject(error);
}
/* custom cache rules*/
workbox.routing.registerNavigationRoute('/index.html', {
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/],
});
} catch (error) {
console.error('[ServiceWorker] Failed to get file from IndexedDB:', path, error);
throw error;
// Cache resources from GDevelop cloudfront server (CORS enabled).
workbox.routing.registerRoute(
/https:\/\/resources\.gdevelop-app\.com\/.*$/,
workbox.strategies.networkFirst({
cacheName: 'gdevelop-resources-cache',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 500,
}),
],
})
);
// TODO: this should be useless?
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg)$/,
workbox.strategies.networkFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 150,
}),
],
})
);
} else {
console.log('Workbox could not be loaded - no offline support');
}
}
/**
* Handles fetch events for browser SW preview files served from IndexedDB.
*/
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Check if this is a request for a browser SW preview file
if (url.pathname.startsWith('/browser_sw_preview/')) {
const relativePath = url.pathname.replace('/browser_sw_preview', '');
event.respondWith((async () => {
try {
// Try to get the file from IndexedDB
const fileRecord = await getBrowserSWPreviewFile(relativePath);
if (!fileRecord) {
console.warn('[ServiceWorker] File not found in IndexedDB:', relativePath);
return new Response('File not found in browser SW preview storage', {
status: 404,
headers: {
'Content-Type': 'text/plain',
}
});
}
// Return the file with appropriate headers
return new Response(fileRecord.bytes, {
status: 200,
headers: {
'Content-Type': fileRecord.contentType || 'application/octet-stream',
// Prevent caching to ensure latest version is always served
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
// CORS headers for cross-origin requests if needed
'Access-Control-Allow-Origin': '*',
}
});
} catch (error) {
console.error('[ServiceWorker] Error serving browser SW preview file:', relativePath, error);
return new Response('Error loading file from browser SW preview storage: ' + error.message, {
status: 500,
headers: {
'Content-Type': 'text/plain',
}
});
}
})());
// Return early to prevent falling through to workbox routes
return;
}
});
self.addEventListener('install', (event) => {
console.log('[ServiceWorker] Installing service worker...');
if (isDev) {
// In development, immediately use a new service worker.
self.skipWaiting();
}
});
self.addEventListener('activate', (event) => {
console.log('[ServiceWorker] Activating service worker...');
if (isDev) {
event.waitUntil(self.clients.claim());
}
});
// ============================================================================
// Standard Workbox Configuration (for "semi-offline"/caching of GDevelop static files and resources)
// ============================================================================
self.__WB_DISABLE_DEV_LOGS = true;
// eslint-disable-next-line no-undef
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
);
/* global workbox */
if (workbox) {
console.log('[ServiceWorker] Workbox loaded successfully');
// Will be replaced by make-service-worker.js to include the proper version.
const VersionMetadata = {};
// Contrary to other static assets (JS, CSS, HTML), libGD.js/wasm are not
// versioned in their filenames. Instead, we version using a query string
// (see src/index.js where it's loaded with the same query string).
workbox.precaching.precacheAndRoute([
{
url: `libGD.js?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
{
url: `libGD.wasm?cache-buster=${VersionMetadata.versionWithHash}`,
revision: null, // Revision is null because versioning included in the URL.
},
]);
/* injection point for manifest files. */
workbox.precaching.precacheAndRoute([]);
/* custom cache rules*/
workbox.routing.registerNavigationRoute('/index.html', {
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/, /^\/browser_sw_preview\//],
});
// Cache resources from GDevelop cloudfront server (CORS enabled).
workbox.routing.registerRoute(
/https:\/\/resources\.gdevelop-app\.com\/.*$/,
workbox.strategies.networkFirst({
cacheName: 'gdevelop-resources-cache',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 500,
}),
],
})
);
// TODO: this should be useless?
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg)$/,
workbox.strategies.networkFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 150,
}),
],
})
);
} else {
console.log('[ServiceWorker] Workbox could not be loaded - no offline support');
console.log(
'importScripts does not exist on this browser - no offline support'
);
}

View File

@@ -337,32 +337,8 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
setUserRequestTextPerRequestId,
] = React.useState<{ [string]: string }>({});
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
const [shouldAutoScroll, setShouldAutoScroll] = React.useState<boolean>(
true
);
const requiredGameId = (aiRequest && aiRequest.gameId) || null;
// Auto-scroll to bottom when content changes, if user is at the bottom
React.useEffect(
() => {
if (shouldAutoScroll && scrollViewRef.current) {
scrollViewRef.current.scrollToBottom({
behavior: 'smooth',
});
}
},
[aiRequest, editorFunctionCallResults, lastSendError, shouldAutoScroll]
);
const onScroll = React.useCallback(
({ remainingScreensToBottom }: { remainingScreensToBottom: number }) => {
// Consider the user is at the bottom when they are less than 0.1 screen away from the bottom.
const isAtBottom = remainingScreensToBottom < 0.1;
setShouldAutoScroll(isAtBottom);
},
[]
);
const newChatPlaceholder = React.useMemo(
() => {
const newChatPlaceholders: Array<MessageDescriptor> =
@@ -766,11 +742,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
[classes.aiRequestChatContainer]: true,
})}
>
<ScrollView
ref={scrollViewRef}
style={styles.chatScrollView}
onScroll={onScroll}
>
<ScrollView ref={scrollViewRef} style={styles.chatScrollView}>
<ChatMessages
aiRequest={aiRequest}
onSendFeedback={onSendFeedback}

View File

@@ -59,7 +59,6 @@ import { prepareAiUserContent } from './PrepareAiUserContent';
import { AiRequestContext } from './AiRequestContext';
import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration';
import { type CreateProjectResult } from '../Utils/UseCreateProject';
import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext';
const gd: libGDevelop = global.gd;
@@ -598,9 +597,6 @@ export const AskAiEditor = React.memo<Props>(
onRefreshLimits,
subscription,
} = React.useContext(AuthenticatedUserContext);
const { openSubscriptionDialog } = React.useContext(
SubscriptionSuggestionContext
);
const availableCredits = limits ? limits.credits.userBalance.amount : 0;
const quota =
@@ -652,18 +648,6 @@ export const AskAiEditor = React.memo<Props>(
if (quota && quota.limitReached && aiRequestPriceInCredits) {
payWithCredits = true;
if (availableCredits < aiRequestPriceInCredits) {
// Not enough credits.
if (!hasValidSubscriptionPlan(subscription)) {
// User is not subscribed, suggest them to subscribe.
openSubscriptionDialog({
analyticsMetadata: {
reason: 'AI requests (subscribe)',
recommendedPlanId: 'gdevelop_gold',
placementId: 'ai-requests',
},
});
return;
}
openCreditsPackageDialog({
missingCredits: aiRequestPriceInCredits - availableCredits,
});
@@ -708,7 +692,7 @@ export const AskAiEditor = React.memo<Props>(
fileMetadata,
storageProviderName,
mode,
toolsVersion: 'v4',
toolsVersion: 'v3',
aiConfiguration: {
presetId: aiConfigurationPresetId,
},
@@ -774,8 +758,6 @@ export const AskAiEditor = React.memo<Props>(
updateAiRequest,
newAiRequestOptions,
onOpenAskAi,
subscription,
openSubscriptionDialog,
]
);
@@ -835,19 +817,6 @@ export const AskAiEditor = React.memo<Props>(
) {
payWithCredits = true;
if (availableCredits < aiRequestPriceInCredits) {
// Not enough credits.
if (!hasValidSubscriptionPlan(subscription)) {
// User is not subscribed, suggest them to subscribe.
openSubscriptionDialog({
analyticsMetadata: {
reason: 'AI requests (subscribe)',
recommendedPlanId: 'gdevelop_gold',
placementId: 'ai-requests',
},
});
return;
}
openCreditsPackageDialog({
missingCredits: aiRequestPriceInCredits - availableCredits,
});
@@ -965,8 +934,6 @@ export const AskAiEditor = React.memo<Props>(
hasFunctionsCallsToProcess,
onOpenAskAi,
onOpenLayout,
subscription,
openSubscriptionDialog,
]
);
const onSendEditorFunctionCallResults = React.useCallback(

View File

@@ -88,8 +88,8 @@ type Props = {|
getCourseCompletion: (courseId: string) => CourseCompletion | null,
noPadding?: boolean,
noActions?: boolean,
fastCheckout?: boolean,
onCloseAfterPurchaseDone?: () => void,
simpleCheckout?: boolean,
onPurchaseDone?: () => void,
|};
const BundleInformationPage = ({
@@ -104,8 +104,8 @@ const BundleInformationPage = ({
getCourseCompletion,
noPadding,
noActions,
fastCheckout,
onCloseAfterPurchaseDone,
simpleCheckout,
onPurchaseDone,
}: Props) => {
const { windowSize, isLandscape, isMobile } = useResponsiveWindowSize();
const { bundleListingDatas } = React.useContext(BundleStoreContext); // If archived, should use the one passed.
@@ -317,8 +317,8 @@ const BundleInformationPage = ({
bundleListingData={bundleListingData}
bundle={bundle}
i18n={i18n}
fastCheckout={fastCheckout}
onCloseAfterPurchaseDone={onCloseAfterPurchaseDone}
simpleCheckout={simpleCheckout}
onPurchaseDone={onPurchaseDone}
/>
<Line noMargin>
<Text size="section-title">

View File

@@ -68,8 +68,8 @@ type Props = {|
bundle: Bundle,
simulateAppStoreProduct?: boolean,
i18n: I18nType,
fastCheckout?: boolean,
onCloseAfterPurchaseDone?: () => void,
simpleCheckout?: boolean,
onPurchaseDone?: () => void,
|};
const BundlePageHeader = ({
@@ -77,8 +77,8 @@ const BundlePageHeader = ({
bundleListingData,
simulateAppStoreProduct,
i18n,
fastCheckout,
onCloseAfterPurchaseDone,
simpleCheckout,
onPurchaseDone,
}: Props) => {
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
@@ -298,7 +298,7 @@ const BundlePageHeader = ({
<ProductLimitedTimeOffer
visibleUntil={bundleListingData.visibleUntil}
/>
) : estimatedTotalPriceFormatted ? (
) : estimatedSavingsFormatted ? (
<Column
noMargin
alignItems="flex-start"
@@ -310,8 +310,9 @@ const BundlePageHeader = ({
/>
<Text color="secondary">
<Trans>
Get {estimatedTotalPriceFormatted} worth of value
for less!
Get{' '}
{estimatedSavingsFormatted.savingsPriceFormatted}{' '}
worth of value for less!
</Trans>
</Text>
</Column>
@@ -506,8 +507,8 @@ const BundlePageHeader = ({
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
fastCheckout={fastCheckout}
onCloseAfterPurchaseDone={onCloseAfterPurchaseDone}
simpleCheckout={simpleCheckout}
onPurchaseDone={onPurchaseDone}
/>
)}
{isRedemptionCodesDialogOpen && (

View File

@@ -25,15 +25,14 @@ import {
} from '../../Utils/AppStorePurchases';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import PasswordPromptDialog from '../PasswordPromptDialog';
import { getUserUUID } from '../../Utils/Analytics/UserUUID';
type Props = {|
bundleListingData: BundleListingData,
usageType: string,
onClose: () => void,
simulateAppStoreProduct?: boolean,
fastCheckout?: boolean,
onCloseAfterPurchaseDone?: () => void,
simpleCheckout?: boolean,
onPurchaseDone?: () => void,
|};
const BundlePurchaseDialog = ({
@@ -41,8 +40,8 @@ const BundlePurchaseDialog = ({
usageType,
onClose,
simulateAppStoreProduct,
fastCheckout,
onCloseAfterPurchaseDone,
simpleCheckout,
onPurchaseDone,
}: Props) => {
const {
profile,
@@ -65,22 +64,16 @@ const BundlePurchaseDialog = ({
] = React.useState<boolean>(false);
const [password, setPassword] = React.useState<string>('');
const { showAlert } = useAlertDialog();
const [isOpeningUrl, setIsOpeningUrl] = React.useState(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const willReceiveAnEmailForThePurchase = !profile && fastCheckout;
const onStartPurchase = async () => {
if (!profile) return;
setDisplayPasswordPrompt(false);
// Purchase with the App Store.
if (shouldUseOrSimulateAppStoreProduct) {
if (!profile) {
return;
}
try {
setIsPurchasing(true);
await purchaseAppStoreProduct(bundleListingData.appStoreProductId);
@@ -105,35 +98,21 @@ const BundlePurchaseDialog = ({
// Purchase with web.
try {
setIsPurchasing(true);
if (fastCheckout) {
const checkoutUrl = getStripeCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile ? profile.id : undefined,
userEmail: profile ? profile.email : undefined,
userUuid: profile ? undefined : getUserUUID(),
password: password || undefined,
});
// Mark the Url as opening if opening in the same tab, as it can take some time to load.
setIsOpeningUrl(willReceiveAnEmailForThePurchase);
Window.openExternalURL(checkoutUrl, {
shouldOpenInSameTabIfPossible: willReceiveAnEmailForThePurchase,
});
return;
}
// Cannot continue without a profile if not doing fast checkout.
if (!profile) {
return;
}
const checkoutUrl = getPurchaseCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
password: password || undefined,
});
const checkoutUrl = simpleCheckout
? getStripeCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
})
: getPurchaseCheckoutUrl({
productId: bundleListingData.id,
priceName: price.name,
userId: profile.id,
userEmail: profile.email,
...(password ? { password } : undefined),
});
Window.openExternalURL(checkoutUrl);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
@@ -176,16 +155,6 @@ const BundlePurchaseDialog = ({
[]
);
const onCloseDialog = React.useCallback(
() => {
if (onCloseAfterPurchaseDone && purchaseSuccessful) {
onCloseAfterPurchaseDone();
}
onClose();
},
[onCloseAfterPurchaseDone, purchaseSuccessful, onClose]
);
// This effect will be triggered when the bundle purchases change,
// to check if the user has just bought the product.
React.useEffect(
@@ -201,6 +170,7 @@ const BundlePurchaseDialog = ({
// We found the purchase, the user has bought the bundle.
// We do not close the dialog yet, as we need to trigger a refresh of the products received.
await onPurchaseSuccessful();
if (onPurchaseDone) onPurchaseDone();
}
};
checkIfPurchaseIsDone();
@@ -210,7 +180,7 @@ const BundlePurchaseDialog = ({
bundlePurchases,
bundleListingData,
onPurchaseSuccessful,
onCloseAfterPurchaseDone,
onPurchaseDone,
onRefreshBundlePurchases,
]
);
@@ -219,7 +189,7 @@ const BundlePurchaseDialog = ({
() => {
onRefreshBundlePurchases();
},
isPurchasing && !willReceiveAnEmailForThePurchase ? 3900 : null
isPurchasing ? 3900 : null
);
// Listen to the received bundle, to know when a user has just logged in and the received bundles have been loaded.
@@ -256,7 +226,7 @@ const BundlePurchaseDialog = ({
setIsPurchasing(false);
setPurchaseSuccessful(true);
} else if (!purchaseSuccessful) {
onCloseDialog();
onClose();
}
}
}
@@ -265,121 +235,106 @@ const BundlePurchaseDialog = ({
receivedBundles,
bundleListingData,
isPurchasing,
onCloseDialog,
onClose,
isCheckingPurchasesAfterLogin,
purchaseSuccessful,
]
);
const dialogContents =
!profile && !fastCheckout
? {
subtitle: <Trans>Log-in to purchase this item</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
Bundles and their content will be linked to your user account
and available for all your projects. Log-in or sign-up to
purchase this bundle. (or restore your existing purchase).
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
const dialogContents = !profile
? {
subtitle: <Trans>Log-in to purchase this item</Trans>,
content: (
<CreateProfile
onOpenLoginDialog={onOpenLoginDialog}
onOpenCreateAccountDialog={onOpenCreateAccountDialog}
message={
<Trans>
Bundles and their content will be linked to your user account
and available for all your projects. Log-in or sign-up to
purchase this bundle. (or restore your existing purchase).
</Trans>
}
justifyContent="center"
/>
),
}
: purchaseSuccessful
? {
subtitle: <Trans>Your purchase has been processed!</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>You can now go back to use your new bundle.</Trans>
</Text>
</Line>
),
}
: isPurchasing
? {
subtitle: shouldUseOrSimulateAppStoreProduct ? (
<Trans>Complete your purchase with the app store.</Trans>
) : (
<Trans>Complete your payment on the web browser</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? (
<>
<ColumnStackLayout justifyContent="center" alignItems="center">
<CircularProgress size={40} />
<Text>
<Trans>You can now go back to use your new bundle.</Trans>
<Trans>
The purchase will be linked to your account once done.
</Trans>
</Text>
</Line>
),
}
: isPurchasing
? {
subtitle: shouldUseOrSimulateAppStoreProduct ? (
<Trans>Complete your purchase with the app store.</Trans>
) : (
<Trans>Complete your payment on the web browser</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? (
<>
<ColumnStackLayout justifyContent="center" alignItems="center">
<CircularProgress size={40} />
<Text>
<Trans>
The purchase will be linked to your account once done.
</Trans>
</Text>
</ColumnStackLayout>
</>
) : (
<>
{!willReceiveAnEmailForThePurchase && (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
<Spacer />
<Text>
<Trans>Waiting for the purchase confirmation...</Trans>
</Text>
</Line>
)}
<Spacer />
<Line justifyContent="center">
<BackgroundText>
{!willReceiveAnEmailForThePurchase ? (
<Trans>
Once you're done, come back to GDevelop and the bundle
will be added to your account automatically.
</Trans>
) : (
<Trans>
Once you're done, you will receive an email confirmation
so that you can link the bundle to your account.
</Trans>
)}
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
</ColumnStackLayout>
</>
) : (
<>
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
</Line>
),
}
: {
subtitle: profile ? (
<Trans>
The bundle {bundleListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
) : (
<Trans>
The bundle {bundleListingData.name} will be sent to the email
address provided in the checkout.
</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? null : (
<Line justifyContent="center" alignItems="center">
<Spacer />
<Text>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
<Trans>Waiting for the purchase confirmation...</Trans>
</Text>
</Line>
),
};
<Spacer />
<Line justifyContent="center">
<BackgroundText>
<Trans>
Once you're done, come back to GDevelop and the bundle will be
added to your account automatically.
</Trans>
</BackgroundText>
</Line>
</>
),
}
: isCheckingPurchasesAfterLogin
? {
subtitle: <Trans>Loading your profile...</Trans>,
content: (
<Line justifyContent="center" alignItems="center">
<CircularProgress size={20} />
</Line>
),
}
: {
subtitle: (
<Trans>
The bundle {bundleListingData.name} will be linked to your account{' '}
{profile.email}.
</Trans>
),
content: shouldUseOrSimulateAppStoreProduct ? null : (
<Line justifyContent="center" alignItems="center">
<Text>
<Trans>
A new secure window will open to complete the purchase.
</Trans>
</Text>
</Line>
),
};
const allowPurchase =
profile &&
@@ -389,17 +344,8 @@ const BundlePurchaseDialog = ({
const dialogActions = [
<FlatButton
key="cancel"
label={
isOpeningUrl ? (
<Trans>Loading...</Trans>
) : purchaseSuccessful || willReceiveAnEmailForThePurchase ? (
<Trans>Close</Trans>
) : (
<Trans>Cancel</Trans>
)
}
onClick={onCloseDialog}
disabled={isOpeningUrl}
label={purchaseSuccessful ? <Trans>Close</Trans> : <Trans>Cancel</Trans>}
onClick={onClose}
/>,
allowPurchase ? (
<DialogPrimaryButton
@@ -417,11 +363,11 @@ const BundlePurchaseDialog = ({
title={<Trans>{bundleListingData.name}</Trans>}
maxWidth="sm"
open
onRequestClose={onCloseDialog}
onRequestClose={onClose}
actions={dialogActions}
onApply={purchaseSuccessful ? onCloseDialog : onWillPurchase}
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
fullscreen="never-even-on-mobile"
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}

View File

@@ -290,7 +290,6 @@ const CreditsPackagePurchaseDialog = ({
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
fullscreen="never-even-on-mobile"
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}

View File

@@ -30,17 +30,15 @@ type CreditsPackageStoreState = {|
openCreditsUsageDialog: CreditsUsageDialogOptions => void,
|};
export const initialCreditsPackageStoreState: CreditsPackageStoreState = {
fetchCreditsPackages: () => {},
creditsPackageListingDatas: null,
error: null,
openCreditsPackageDialog: () => {},
closeCreditsPackageDialog: () => {},
openCreditsUsageDialog: () => {},
};
export const CreditsPackageStoreContext = React.createContext<CreditsPackageStoreState>(
initialCreditsPackageStoreState
{
fetchCreditsPackages: () => {},
creditsPackageListingDatas: null,
error: null,
openCreditsPackageDialog: () => {},
closeCreditsPackageDialog: () => {},
openCreditsUsageDialog: () => {},
}
);
// Ids are in the form "amount_credits" (e.g: "500_credits").

View File

@@ -355,7 +355,6 @@ const PrivateAssetPackPurchaseDialog = ({
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
fullscreen="never-even-on-mobile"
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}

View File

@@ -356,7 +356,6 @@ const PrivateGameTemplatePurchaseDialog = ({
onApply={purchaseSuccessful ? onClose : onWillPurchase}
cannotBeDismissed // Prevent the user from continuing by clicking outside.
flexColumnBody
fullscreen="never-even-on-mobile"
>
<LineStackLayout justifyContent="center" alignItems="center">
{purchaseSuccessful && <Mark />}

View File

@@ -341,13 +341,9 @@ export const getProductsIncludedInBundle = <
));
if (!includedProductIds) return null;
return includedProductIds
.map(includedProductId =>
productListingDatas.find(
productListingData => productListingData.id === includedProductId
)
)
.filter(Boolean);
return productListingDatas.filter(productListingData =>
includedProductIds.includes(productListingData.id)
);
};
export const getProductsIncludedInBundleTiles = ({

View File

@@ -9,7 +9,7 @@ import './UI/icomoon-font.css'; // Styles for Icomoon font.
// Import for browser only IDE
import browserResourceSources from './ResourcesList/BrowserResourceSources';
import browserResourceExternalEditors from './ResourcesList/BrowserResourceExternalEditors';
import BrowserSWPreviewLauncher from './ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher';
import BrowserS3PreviewLauncher from './ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher';
import {
browserAutomatedExporters,
browserManualExporters,
@@ -18,7 +18,7 @@ import {
import makeExtensionsLoader from './JsExtensionsLoader/BrowserJsExtensionsLoader';
import ObjectsEditorService from './ObjectEditor/ObjectsEditorService';
import ObjectsRenderingService from './ObjectsRendering/ObjectsRenderingService';
import { makeBrowserSWEventsFunctionCodeWriter } from './EventsFunctionsExtensionsLoader/CodeWriters/BrowserSWEventsFunctionCodeWriter';
import { makeBrowserS3EventsFunctionCodeWriter } from './EventsFunctionsExtensionsLoader/CodeWriters/BrowserS3EventsFunctionCodeWriter';
import Providers from './MainFrame/Providers';
import ProjectStorageProviders from './ProjectsStorage/ProjectStorageProviders';
import UrlStorageProvider from './ProjectsStorage/UrlStorageProvider';
@@ -43,7 +43,7 @@ export const create = (authentication: Authentication) => {
<Providers
authentication={authentication}
disableCheckForUpdates={!!appArguments['disable-update-check']}
makeEventsFunctionCodeWriter={makeBrowserSWEventsFunctionCodeWriter}
makeEventsFunctionCodeWriter={makeBrowserS3EventsFunctionCodeWriter}
eventsFunctionsExtensionWriter={BrowserEventsFunctionsExtensionWriter}
eventsFunctionsExtensionOpener={BrowserEventsFunctionsExtensionOpener}
>
@@ -68,7 +68,7 @@ export const create = (authentication: Authentication) => {
<MainFrame
i18n={i18n}
renderPreviewLauncher={(props, ref) => (
<BrowserSWPreviewLauncher {...props} ref={ref} />
<BrowserS3PreviewLauncher {...props} ref={ref} />
)}
renderShareDialog={props => (
<ShareDialog

View File

@@ -114,11 +114,7 @@ const createField = (
// Choice is a "string" (with a selector for the user in the UI)
const choices = mapVector(property.getChoices(), choice => ({
value: choice.getValue(),
label:
choice.getValue() +
(choice.getLabel() && choice.getLabel() !== choice.getValue()
? `${choice.getLabel()}`
: ''),
label: choice.getLabel(),
}));
// TODO Remove this once we made sure no built-in extension still use `addExtraInfo` instead of `addChoice`.
const deprecatedChoices = property

View File

@@ -15,7 +15,7 @@ type CourseChapterStoreState = {|
listedCourseChapters: CourseChapterListingData[] | null,
|};
export const initialCourseStoreState: CourseChapterStoreState = {
const initialCourseStoreState: CourseChapterStoreState = {
listedCourses: null,
listedCourseChapters: null,
};

View File

@@ -3,10 +3,7 @@ import * as React from 'react';
import { getInstancesInLayoutForLayer } from '../Utils/Layout';
import { mapFor, mapVector } from '../Utils/MapFor';
import { SafeExtractor } from '../Utils/SafeExtractor';
import {
serializeToJSObject,
unserializeFromJSObject,
} from '../Utils/Serializer';
import { serializeToJSObject } from '../Utils/Serializer';
import { type AiGeneratedEvent } from '../Utils/GDevelopServices/Generation';
import { renderNonTranslatedEventsAsText } from '../EventsSheet/EventsTree/TextRenderer';
import {
@@ -417,10 +414,6 @@ const createOrReplaceObject: EditorFunction = {
args,
'replace_existing_object'
);
const duplicatedObjectName = SafeExtractor.extractStringProperty(
args,
'duplicated_object_name'
);
return {
text: replaceExistingObject ? (
@@ -440,24 +433,6 @@ const createOrReplaceObject: EditorFunction = {
</Link>
.
</Trans>
) : duplicatedObjectName ? (
<Trans>
Duplicate object <b>{duplicatedObjectName}</b> as <b>{object_name}</b>{' '}
in scene{' '}
<Link
href="#"
onClick={() =>
editorCallbacks.onOpenLayout(scene_name, {
openEventsEditor: true,
openSceneEditor: true,
focusWhenOpened: 'scene',
})
}
>
{scene_name}
</Link>
.
</Trans>
) : (
<Trans>
Create object <b>{object_name}</b> in scene{' '}
@@ -491,10 +466,6 @@ const createOrReplaceObject: EditorFunction = {
args,
'replace_existing_object'
);
const duplicatedObjectName = SafeExtractor.extractStringProperty(
args,
'duplicated_object_name'
);
const description = SafeExtractor.extractStringProperty(
args,
'description'
@@ -691,45 +662,7 @@ const createOrReplaceObject: EditorFunction = {
);
};
const duplicateExistingObject = (duplicatedObjectName: string) => {
const object = objectsContainer.getObject(duplicatedObjectName);
const serializedObject = serializeToJSObject(object);
const newObject = objectsContainer.insertNewObject(
project,
object.getType(),
object_name,
objectsContainer.getObjectsCount()
);
unserializeFromJSObject(
newObject,
serializedObject,
'unserializeFrom',
project
);
newObject.setName(object_name); // Unserialization has overwritten the name.
return makeGenericSuccess(
`Duplicated object "${duplicatedObjectName}" as "${newObject.getName()}". The new object "${newObject.getName()}" has the same type, behaviors, properties and effects as the one it was duplicated from.`
);
};
if (shouldReplaceExistingObject) {
// Replace an existing object, if there is one existing.
if (!objectsContainer.hasObjectNamed(object_name)) {
return createNewObject();
}
return replaceExistingObject();
} else if (duplicatedObjectName) {
// Duplicate an existing object, if there is one existing.
if (!objectsContainer.hasObjectNamed(duplicatedObjectName)) {
return makeGenericFailure(
`Object with name "${duplicatedObjectName}" does not exist in scene "${scene_name}", cannot duplicate it into ${object_name}.`
);
}
return duplicateExistingObject(duplicatedObjectName);
} else {
if (!shouldReplaceExistingObject) {
// Add a new object.
if (objectsContainer.hasObjectNamed(object_name)) {
if (objectsContainer.getObject(object_name).getType() !== object_type) {
@@ -744,6 +677,13 @@ const createOrReplaceObject: EditorFunction = {
}
return createNewObject();
} else {
// Replace an existing object, if there is one existing.
if (!objectsContainer.hasObjectNamed(object_name)) {
return createNewObject();
}
return replaceExistingObject();
}
},
};
@@ -1912,11 +1852,7 @@ const put2dInstances: EditorFunction = {
};
}
},
launchFunction: async ({
project,
args,
onInstancesModifiedOutsideEditor,
}) => {
launchFunction: async ({ project, args }) => {
const scene_name = extractRequiredString(args, 'scene_name');
const object_name = SafeExtractor.extractStringProperty(
args,
@@ -2019,12 +1955,6 @@ const put2dInstances: EditorFunction = {
initialInstances.removeInstance(instance);
});
// /!\ Tell the editor that some instances have potentially been modified (and even removed).
// This will force the instances editor to destroy and mount again the
// renderers to avoid keeping any references to existing instances, and also drop any selection.
onInstancesModifiedOutsideEditor({
scene: layout,
});
return makeGenericSuccess(
[
`Erased ${instancesToDelete.size} instance${
@@ -2074,12 +2004,8 @@ const put2dInstances: EditorFunction = {
`You've specified to create ${newInstancesCount} instances, but you didn't specify the object name. Please specify the object name.`
);
}
if (
object_name &&
!objectsContainer.hasObjectNamed(object_name) &&
!project.getObjects().hasObjectNamed(object_name)
) {
// TODO: make this work for global objects.
if (object_name && !objectsContainer.hasObjectNamed(object_name)) {
return makeGenericFailure(
`Object not found: "${object_name}" in scene "${scene_name}". Please only specify the object name of an object existing in the scene (or create if before if necessary).`
);
@@ -2348,12 +2274,6 @@ const put2dInstances: EditorFunction = {
);
}
// /!\ Tell the editor that some instances have potentially been modified (and even removed).
// This will force the instances editor to destroy and mount again the
// renderers to avoid keeping any references to existing instances, and also drop any selection.
onInstancesModifiedOutsideEditor({
scene: layout,
});
return makeGenericSuccess(changes.join(' '));
}
},
@@ -2456,11 +2376,7 @@ const put3dInstances: EditorFunction = {
};
}
},
launchFunction: async ({
project,
args,
onInstancesModifiedOutsideEditor,
}) => {
launchFunction: async ({ project, args }) => {
const scene_name = extractRequiredString(args, 'scene_name');
const object_name = SafeExtractor.extractStringProperty(
args,
@@ -2565,12 +2481,6 @@ const put3dInstances: EditorFunction = {
initialInstances.removeInstance(instance);
});
// /!\ Tell the editor that some instances have potentially been modified (and even removed).
// This will force the instances editor to destroy and mount again the
// renderers to avoid keeping any references to existing instances, and also drop any selection.
onInstancesModifiedOutsideEditor({
scene: layout,
});
return makeGenericSuccess(
[
`Erased ${instancesToDelete.size} instance${
@@ -2613,12 +2523,8 @@ const put3dInstances: EditorFunction = {
`You've specified to create ${newInstancesCount} instances, but you didn't specify the object name. Please specify the object name.`
);
}
if (
object_name &&
!objectsContainer.hasObjectNamed(object_name) &&
!project.getObjects().hasObjectNamed(object_name)
) {
// TODO: make this work for global objects.
if (object_name && !objectsContainer.hasObjectNamed(object_name)) {
return makeGenericFailure(
`Object not found: "${object_name}" in scene "${scene_name}". Please only specify the object name of an object existing in the scene (or create if before if necessary).`
);
@@ -2849,12 +2755,6 @@ const put3dInstances: EditorFunction = {
return makeGenericSuccess('No changes were made to instances.');
}
// /!\ Tell the editor that some instances have potentially been modified (and even removed).
// This will force the instances editor to destroy and mount again the
// renderers to avoid keeping any references to existing instances, and also drop any selection.
onInstancesModifiedOutsideEditor({
scene: layout,
});
return makeGenericSuccess(changes.join(' '));
}
},

View File

@@ -124,12 +124,7 @@ export const enumerateEffectsMetadata = (
} else if (valueType === 'choice') {
const choices = mapVector(property.getChoices(), choice => ({
value: choice.getValue(),
label:
choice.getValue() +
(choice.getLabel() &&
choice.getLabel() !== choice.getValue()
? `${choice.getLabel()}`
: ''),
label: choice.getLabel(),
}));
const deprecatedChoices = property
.getExtraInfo()

View File

@@ -964,13 +964,7 @@ export default function EventsBasedBehaviorPropertiesEditor({
<SelectOption
key={index}
value={choice.value}
label={
choice.value +
(choice.label &&
choice.label !== choice.value
? `${choice.label}`
: '')
}
label={choice.value}
/>
)
)}

View File

@@ -887,12 +887,7 @@ export default function EventsBasedObjectPropertiesEditor({
<SelectOption
key={index}
value={choice.value}
label={
choice.value +
(choice.label
? `${choice.label}`
: '')
}
label={choice.label}
/>
)
)}

View File

@@ -557,9 +557,7 @@ export const EventsFunctionPropertiesEditor = ({
kind="info"
renderRightButton={() => (
<HelpButton
helpPagePath={
'/events/functions/asynchronous-functions'
}
helpPagePath={'/functions/asynchronous-functions'}
/>
)}
>

View File

@@ -1,139 +0,0 @@
// @flow
import {
type EventsFunctionCodeWriter,
type EventsFunctionCodeWriterCallbacks,
} from '..';
import {
deleteFilesWithPrefix,
putFile,
getBrowserSWPreviewBaseUrl,
} from '../../ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
import slugs from 'slugs';
import debounce from 'lodash/debounce';
let batchedWrites: Array<{
path: string,
content: string,
onSuccess: () => void,
onError: (error: Error) => void,
}> = [];
const flushBatchedWrites = debounce(async () => {
const writes = [...batchedWrites];
console.info(
`[BrowserSWEventsFunctionCodeWriter] Storing a batch of ${
writes.length
} extension generated files in IndexedDB...`,
writes.map(w => w.path)
);
batchedWrites = [];
// Write all files to IndexedDB in parallel
const results = await Promise.allSettled(
writes.map(async write => {
const encoder = new TextEncoder();
const bytes = encoder.encode(write.content).buffer;
await putFile(write.path, bytes, 'text/javascript; charset=utf-8');
return write;
})
);
// Process results and call callbacks
results.forEach((result, index) => {
const write = writes[index];
if (result.status === 'fulfilled') {
write.onSuccess();
} else {
console.error(
`[BrowserSWEventsFunctionCodeWriter] Failed to store: ${write.path}`,
result.reason
);
write.onError(result.reason);
}
});
}, 10); // Wait for up to 10ms, to avoid adding more latency to extension generation.
/**
* Write a file by batching it with other files that are being written.
*
* Extension generated files are written in batches to avoid making a *lot* of IndexedDB transactions
* (games can have from dozens to **hundreds** of extensions and generated files).
*/
const writeFileInNextBatch = (path: string, content: string) => {
return new Promise((resolve, reject) => {
batchedWrites.push({
path,
content,
onSuccess: resolve,
onError: reject,
});
flushBatchedWrites();
});
};
/**
* Create the EventsFunctionCodeWriter that writes generated code for events functions
* to IndexedDB for service worker serving.
*/
export const makeBrowserSWEventsFunctionCodeWriter = ({
onWriteFile,
}: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => {
const baseUrl = getBrowserSWPreviewBaseUrl();
const extensionsCodeUrl = baseUrl + '/extensions-code';
// At startup, clean up the old generated files for extensions code.
(async () => {
try {
// TODO: maybe don't do it at startup because this could break multiple tabs!
// TODO: Also consider doing a preview per tab?
await deleteFilesWithPrefix(extensionsCodeUrl + '/');
} catch (error) {
console.error(
`[BrowserSWEventsFunctionCodeWriter] Failed to clean generated files for extensions code in "${extensionsCodeUrl}/".`,
error
);
}
})();
const getPathFor = (codeNamespace: string) => {
return `${extensionsCodeUrl}/${slugs(codeNamespace)}.js`;
};
return {
getIncludeFileFor: (codeNamespace: string) => getPathFor(codeNamespace),
writeFunctionCode: (
functionCodeNamespace: string,
code: string
): Promise<void> => {
const path = getPathFor(functionCodeNamespace);
onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, '');
return writeFileInNextBatch(relativePath, code);
},
writeBehaviorCode: (
behaviorCodeNamespace: string,
code: string
): Promise<void> => {
const path = getPathFor(behaviorCodeNamespace);
onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, '');
return writeFileInNextBatch(relativePath, code);
},
writeObjectCode: (
objectCodeNamespace: string,
code: string
): Promise<void> => {
const path = getPathFor(objectCodeNamespace);
onWriteFile({ includeFile: path, content: code });
const relativePath = path.replace(baseUrl, '');
return writeFileInNextBatch(relativePath, code);
},
};
};

View File

@@ -38,6 +38,10 @@ const isURL = (filename: string) => {
// Search for "pathPosix" in the codebase for other places where this is used.
const pathPosix = path.posix || path;
// TODO: Merge BrowserS3FileSystem into this? The way URLs are handled
// is different though (BrowserS3FileSystem is not downloading anything
// from URLs, while this file system does download files).
/**
* An in-memory "file system" that can be used for GDevelop exports.
*/

View File

@@ -59,21 +59,19 @@ const setupWindowClosedPolling = () => {
}, 1000);
};
let previewOrigin = null;
const PREVIEWS_ORIGIN = 'https://game-previews.gdevelop.io';
/**
* A debugger server implemented using the ability to send/receive messages
* from popup windows in the browser.
*/
export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
startServer: async ({ origin }) => {
startServer: async () => {
if (debuggerServerState === 'started') return;
debuggerServerState = 'started';
previewOrigin = origin;
window.addEventListener('message', event => {
if (event.origin !== previewOrigin) return;
if (event.origin !== PREVIEWS_ORIGIN) return;
const id = getDebuggerIdForPreviewWindow(event.source);
if (id === null) return; // Could not find the id of this preview window.
@@ -100,7 +98,7 @@ export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
if (!previewWindow) return;
try {
previewWindow.postMessage(message, previewOrigin);
previewWindow.postMessage(message, PREVIEWS_ORIGIN);
} catch (error) {
console.error('Unable to send a message to the preview window:', error);
}

View File

@@ -20,7 +20,6 @@ import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternal
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version';
import { getBrowserSWPreviewBaseUrl } from '../BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd;
type State = {|
@@ -53,7 +52,6 @@ export const immediatelyOpenNewPreviewWindow = (
"Can't open the preview window because of browser restrictions."
);
}
console.log('Preview window opened', previewWindow);
displayBlackLoadingScreenOrThrow(previewWindow);
@@ -137,9 +135,7 @@ export default class BrowserS3PreviewLauncher extends React.Component<
}).filter(Boolean);
try {
await this.getPreviewDebuggerServer().startServer({
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
});
await this.getPreviewDebuggerServer().startServer();
} catch (err) {
// Ignore any error when running the debugger server - the preview
// can still work without it.

View File

@@ -1,254 +0,0 @@
// @flow
import path from 'path-browserify';
import {
deleteFilesWithPrefix,
putFile,
} from './BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd;
export type TextFileDescriptor = {|
filePath: string,
text: string,
|};
type ConstructorArgs = {|
filesContent: Array<TextFileDescriptor>,
baseUrl: string,
|};
const isURL = (filename: string) => {
return (
filename.startsWith('http://') ||
filename.startsWith('https://') ||
filename.startsWith('ftp://') ||
filename.startsWith('blob:') ||
filename.startsWith('data:')
);
};
/**
* Determines the content type based on file extension.
*/
const getContentType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
};
return mimeTypes[ext] || 'application/octet-stream';
};
/**
* An in-memory "file system" that stores files in IndexedDB
* and serves them via a service worker for GDevelop previews.
*/
export default class BrowserSWFileSystem {
baseUrl: string;
// Store the content of some files.
_indexedFilesContent: { [string]: TextFileDescriptor };
// Store all the files that should be written to IndexedDB.
_pendingFiles: Array<{|
path: string,
content: string,
contentType: string,
|}> = [];
_pendingDeleteOperations: Array<Promise<any>> = [];
// Store a set of all external URLs copied so that we can simulate
// readDir result.
_allCopiedExternalUrls = new Set<string>();
constructor({ filesContent, baseUrl }: ConstructorArgs) {
this.baseUrl = baseUrl;
this._indexedFilesContent = {};
filesContent.forEach(textFileDescriptor => {
this._indexedFilesContent[
textFileDescriptor.filePath
] = textFileDescriptor;
});
}
/**
* Uploads all pending files to IndexedDB.
*/
applyPendingOperations = async () => {
try {
await Promise.all(this._pendingDeleteOperations);
} catch (error) {
console.error(
'[BrowserSWFileSystem] Error while deleting files in IndexedDB. Ignoring.',
error
);
}
try {
console.log(
`[BrowserSWFileSystem] Storing ${
this._pendingFiles.length
} files in IndexedDB for preview...`
);
const uploadPromises = this._pendingFiles.map(async file => {
const fullPath = `/${file.path}`; // TODO
const encoder = new TextEncoder();
const bytes = encoder.encode(file.content).buffer;
console.log(
`[BrowserSWFileSystem] Storing file: ${fullPath} (${
bytes.byteLength
} bytes, ${file.contentType})`
);
await putFile(fullPath, bytes, file.contentType);
});
await Promise.all(uploadPromises);
console.log(
`[BrowserSWFileSystem] Successfully stored all ${
this._pendingFiles.length
} preview files in IndexedDB.`
);
} catch (error) {
console.error(
"[BrowserSWFileSystem] Can't store all files in IndexedDB:",
error
);
throw error;
}
};
mkDir = (path: string) => {
// Assume required directories always exist in a virtual file system.
};
dirExists = (path: string) => {
// Assume required directories always exist.
return true;
};
clearDir = (path: string) => {
// TODO: add to a pending operation list so we ensure it's executed.
console.info(`[BrowserSWFileSystem] Clearing directory: ${path}...`);
this._pendingDeleteOperations.push(deleteFilesWithPrefix(path));
};
getTempDir = () => {
return '/virtual-unused-tmp-dir';
};
fileNameFrom = (fullpath: string) => {
if (isURL(fullpath)) return fullpath;
return path.basename(fullpath);
};
dirNameFrom = (fullpath: string) => {
if (isURL(fullpath)) return '';
return path.dirname(fullpath);
};
makeAbsolute = (filename: string, baseDirectory: string) => {
if (isURL(filename)) return filename;
if (!this.isAbsolute(baseDirectory))
baseDirectory = path.resolve(baseDirectory);
return path.resolve(baseDirectory, path.normalize(filename));
};
makeRelative = (filename: string, baseDirectory: string) => {
if (isURL(filename)) return filename;
return path.relative(baseDirectory, path.normalize(filename));
};
isAbsolute = (fullpath: string) => {
if (isURL(fullpath)) return true;
if (fullpath.length === 0) return true;
return (
(fullpath.length > 0 && fullpath.charAt(0) === '/') ||
(fullpath.length > 1 && fullpath.charAt(1) === ':')
);
};
copyFile = (source: string, dest: string) => {
// URLs are not copied, just tracked.
if (isURL(source)) {
this._allCopiedExternalUrls.add(source);
return true;
}
console.warn(
'[BrowserSWFileSystem] Copy not done from',
source,
'to',
dest
);
return true;
};
writeToFile = (fullPath: string, contents: string) => {
// Remove the base URL to get the relative path
const relativePath = fullPath.replace(this.baseUrl, '');
const contentType = getContentType(fullPath);
console.log(
`[BrowserSWFileSystem] Queuing file for IndexedDB: ${relativePath} (${contentType})`
);
// Queue the file to be written to IndexedDB
this._pendingFiles.push({
path: relativePath,
content: contents,
contentType,
});
return true;
};
readFile = (file: string) => {
if (!!this._indexedFilesContent[file])
return this._indexedFilesContent[file].text;
console.error(
`[BrowserSWFileSystem] Unknown file ${file}, returning an empty string`
);
return '';
};
readDir = (path: string, ext: string) => {
ext = ext.toUpperCase();
var output = new gd.VectorString();
// Simulate ReadDir by returning all external URLs
// with the filename matching the extension.
this._allCopiedExternalUrls.forEach(url => {
const upperCaseUrl = url.toUpperCase();
if (upperCaseUrl.indexOf(ext) === upperCaseUrl.length - ext.length) {
output.push_back(url);
}
});
return output;
};
fileExists = (filename: string) => {
if (isURL(filename)) return true;
// Assume all files asked for exist.
return true;
};
}

View File

@@ -1,238 +0,0 @@
// @flow
/**
* A utility module for managing local game preview files in IndexedDB.
* This provides a clean, promise-based interface for storing and retrieving
* game files that will be served by the service worker.
*/
// If updated, also update the service worker template.
const DB_NAME = 'gdevelop-browser-sw-preview';
const STORE_NAME = 'files';
const DB_VERSION = 1;
type FileRecord = {|
bytes: ArrayBuffer,
contentType: string,
|};
let dbInstance: ?IDBDatabase = null;
/**
* Gets the base URL for browser service worker previews.
* This URL should be handled by the service worker to serve files from IndexedDB.
*/
export const getBrowserSWPreviewBaseUrl = (): string => {
// Use the current origin to ensure the service worker can intercept requests
const origin = window.location.origin;
return `${origin}/browser_sw_preview`;
};
/**
* Opens or returns the existing IndexedDB database connection.
* Handles database upgrades and version management.
*/
const openBrowserSWPreviewIndexedDB = (): Promise<IDBDatabase> => {
if (dbInstance && dbInstance.version === DB_VERSION) {
return Promise.resolve(dbInstance);
}
return new Promise((resolve, reject) => {
try {
// $FlowFixMe - indexedDB is available in all browsers
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
const error = request.error || new Error('Failed to open IndexedDB');
console.error('[BrowserSWIndexedDB] Error opening database:', error);
reject(error);
};
request.onsuccess = () => {
dbInstance = request.result;
console.log('[BrowserSWIndexedDB] Database opened successfully');
// Handle unexpected close
dbInstance.onclose = () => {
console.warn(
'[BrowserSWIndexedDB] Database connection closed unexpectedly'
);
dbInstance = null;
};
// Handle version change (e.g., if another tab upgrades the DB)
dbInstance.onversionchange = () => {
console.warn(
'[BrowserSWIndexedDB] Database version changed, closing connection'
);
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
};
resolve(dbInstance);
};
request.onupgradeneeded = event => {
console.log('[BrowserSWIndexedDB] Upgrading database schema...');
const db = request.result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
console.log('[BrowserSWIndexedDB] Created object store:', STORE_NAME);
}
};
request.onblocked = () => {
console.warn(
'[BrowserSWIndexedDB] Database upgrade blocked by another connection'
);
reject(
new Error(
'Database upgrade blocked. Please close other tabs using this application.'
)
);
};
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Exception while opening database:',
error
);
reject(error);
}
});
};
/**
* Stores a "file" in IndexedDB.
*/
export const putFile = async (
path: string,
bytes: ArrayBuffer,
contentType: string
): Promise<void> => {
try {
const db = await openBrowserSWPreviewIndexedDB();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error(
'[BrowserSWIndexedDB] Transaction error while putting file:',
path,
error
);
reject(error);
};
transaction.oncomplete = () => {
resolve();
};
// TODO: add onabort?
const objectStore = transaction.objectStore(STORE_NAME);
const record: FileRecord = { bytes, contentType };
const request = objectStore.put(record, path);
request.onerror = () => {
const error = request.error || new Error('Put operation failed');
console.error(
'[BrowserSWIndexedDB] Error storing file:',
path,
error
);
reject(error);
};
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Exception during put operation:',
path,
error
);
reject(error);
}
});
} catch (error) {
console.error('[BrowserSWIndexedDB] Failed to put file:', path, error);
throw error;
}
};
/**
* Deletes all "files" stored in IndexedDB with a given path prefix.
*
* @param pathPrefix - The path prefix to match.
*/
export const deleteFilesWithPrefix = async (
pathPrefix: string
): Promise<number> => {
try {
const db = await openBrowserSWPreviewIndexedDB();
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction(STORE_NAME, 'readwrite');
let deletedCount = 0;
transaction.onerror = () => {
const error = transaction.error || new Error('Transaction failed');
console.error(
'[BrowserSWIndexedDB] Transaction error while deleting files with prefix:',
pathPrefix,
error
);
reject(error);
};
transaction.oncomplete = () => {
resolve(deletedCount);
};
const objectStore = transaction.objectStore(STORE_NAME);
const request = objectStore.openCursor();
request.onsuccess = event => {
const cursor = event.target.result;
if (cursor) {
const key = cursor.key;
if (typeof key === 'string' && key.startsWith(pathPrefix)) {
cursor.delete();
deletedCount++;
}
cursor.continue();
}
};
request.onerror = () => {
const error = request.error || new Error('Cursor operation failed');
console.error(
'[BrowserSWIndexedDB] Error during cursor operation:',
error
);
reject(error);
};
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Exception during delete with prefix:',
pathPrefix,
error
);
reject(error);
}
});
} catch (error) {
console.error(
'[BrowserSWIndexedDB] Failed to delete files with prefix:',
pathPrefix,
error
);
throw error;
}
};

View File

@@ -1,314 +0,0 @@
// @flow
import * as React from 'react';
import BrowserPreviewErrorDialog from '../BrowserS3PreviewLauncher/BrowserPreviewErrorDialog';
import BrowserSWFileSystem from '../BrowserSWFileSystem';
import { findGDJS } from '../../../GameEngineFinder/BrowserS3GDJSFinder';
import assignIn from 'lodash/assignIn';
import {
type PreviewOptions,
type PreviewLauncherProps,
} from '../../PreviewLauncher.flow';
import {
browserPreviewDebuggerServer,
registerNewPreviewWindow,
} from '../BrowserS3PreviewLauncher/BrowserPreviewDebuggerServer';
import Window from '../../../Utils/Window';
import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternalWindowUtils';
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version';
import { getBrowserSWPreviewBaseUrl } from './BrowserSWPreviewIndexedDB';
const gd: libGDevelop = global.gd;
type State = {|
error: ?Error,
|};
let nextPreviewWindowId = 0;
/**
* Open a window showing a black "loading..." screen. It's important this is done
* NOT in an asynchronous way but JUST after a click. Otherwise, browsers like Safari
* will block the window opening.
*/
export const immediatelyOpenNewPreviewWindow = (
project: gdProject
): WindowProxy => {
const width = project.getGameResolutionWidth();
const height = project.getGameResolutionHeight();
const left = window.screenX + window.innerWidth / 2 - width / 2;
const top = window.screenY + window.innerHeight / 2 - height / 2;
const targetId = 'GDevelopPreview' + nextPreviewWindowId++;
const previewWindow = window.open(
'about:blank',
targetId,
`width=${width},height=${height},left=${left},top=${top}`
);
if (!previewWindow) {
throw new Error(
"Can't open the preview window because of browser restrictions."
);
}
displayBlackLoadingScreenOrThrow(previewWindow);
return previewWindow;
};
export default class BrowserSWPreviewLauncher extends React.Component<
PreviewLauncherProps,
State
> {
canDoNetworkPreview = () => false;
canDoHotReload = () => false;
state = {
error: null,
};
_prepareExporter = (): Promise<{|
outputDir: string,
exporter: gdjsExporter,
browserSWFileSystem: BrowserSWFileSystem,
|}> => {
return findGDJS('preview').then(({ gdjsRoot, filesContent }) => {
console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot);
const isForInGameEdition = false; // TODO: adapt for the 3D editor branch.
const baseUrl = getBrowserSWPreviewBaseUrl();
const outputDir = `${baseUrl}/${
isForInGameEdition ? 'in-game-editor-preview' : 'preview'
}`;
console.log(
'[BrowserSWPreviewLauncher] Preview will be served from:',
outputDir
);
const browserSWFileSystem = new BrowserSWFileSystem({
filesContent,
baseUrl: `${baseUrl}/`,
});
const fileSystem = assignIn(
new gd.AbstractFileSystemJS(),
browserSWFileSystem
);
const exporter = new gd.Exporter(fileSystem, gdjsRoot);
exporter.setCodeOutputDirectory(outputDir);
return {
exporter,
outputDir,
browserSWFileSystem,
};
});
};
launchPreview = async (previewOptions: PreviewOptions): Promise<any> => {
const { project, layout, externalLayout, numberOfWindows } = previewOptions;
this.setState({
error: null,
});
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length;
// Immediately open windows (otherwise Safari will block the window opening if done after
// an asynchronous operation).
const previewWindows = shouldHotReload
? []
: Array.from({ length: numberOfWindows }, () => {
try {
return immediatelyOpenNewPreviewWindow(project);
} catch (error) {
console.error(
'[BrowserSWPreviewLauncher] Unable to open a new preview window - this window will be ignored:',
error
);
return null;
}
}).filter(Boolean);
try {
await this.getPreviewDebuggerServer().startServer({
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
});
} catch (err) {
// Ignore any error when running the debugger server - the preview
// can still work without it.
console.error(
'[BrowserSWPreviewLauncher] Unable to start the Debugger Server for the preview:',
err
);
}
try {
const {
exporter,
outputDir,
browserSWFileSystem,
} = await this._prepareExporter();
const previewExportOptions = new gd.PreviewExportOptions(
project,
outputDir
);
previewExportOptions.setLayoutName(layout.getName());
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
if (externalLayout) {
previewExportOptions.setExternalLayoutName(externalLayout.getName());
}
previewExportOptions.useWindowMessageDebuggerClient();
const includeFileHashs = this.props.getIncludeFileHashs();
for (const includeFile in includeFileHashs) {
const hash = includeFileHashs[includeFile];
previewExportOptions.setIncludeFileHash(includeFile, hash);
}
previewExportOptions.setProjectDataOnlyExport(
// Only export project data if asked and if a hot-reloading is being done.
shouldHotReload && previewOptions.projectDataOnlyExport
);
previewExportOptions.setFullLoadingScreen(
previewOptions.fullLoadingScreen
);
previewExportOptions.setNativeMobileApp(isNativeMobileApp());
previewExportOptions.setGDevelopVersionWithHash(getIDEVersionWithHash());
previewExportOptions.setCrashReportUploadLevel(
this.props.crashReportUploadLevel
);
previewExportOptions.setPreviewContext(this.props.previewContext);
previewExportOptions.setProjectTemplateSlug(project.getTemplateSlug());
previewExportOptions.setSourceGameId(this.props.sourceGameId);
if (previewOptions.inAppTutorialMessageInPreview) {
previewExportOptions.setInAppTutorialMessageInPreview(
previewOptions.inAppTutorialMessageInPreview,
previewOptions.inAppTutorialMessagePositionInPreview
);
}
if (previewOptions.fallbackAuthor) {
previewExportOptions.setFallbackAuthor(
previewOptions.fallbackAuthor.id,
previewOptions.fallbackAuthor.username
);
}
if (previewOptions.authenticatedPlayer) {
previewExportOptions.setAuthenticatedPlayer(
previewOptions.authenticatedPlayer.playerId,
previewOptions.authenticatedPlayer.playerUsername,
previewOptions.authenticatedPlayer.playerToken
);
}
if (previewOptions.captureOptions.screenshots) {
previewOptions.captureOptions.screenshots.forEach(screenshot => {
previewExportOptions.addScreenshotCapture(
screenshot.delayTimeInSeconds,
screenshot.signedUrl,
screenshot.publicUrl
);
});
}
// The token, if any, to be used to read resources on GDevelop Cloud buckets.
const gdevelopResourceToken = getGDevelopResourceJwtToken();
if (gdevelopResourceToken)
previewExportOptions.setGDevelopResourceToken(gdevelopResourceToken);
console.log(
'[BrowserSWPreviewLauncher] Exporting project for preview...'
);
exporter.exportProjectForPixiPreview(previewExportOptions);
previewExportOptions.delete();
exporter.delete();
console.log(
'[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...'
);
await browserSWFileSystem.applyPendingOperations();
if (shouldHotReload) {
console.log('[BrowserSWPreviewLauncher] Triggering hot reload...');
debuggerIds.forEach(debuggerId => {
this.getPreviewDebuggerServer().sendMessage(debuggerId, {
command: 'hotReload',
});
});
} else {
console.log(
'[BrowserSWPreviewLauncher] Opening new preview window(s)...'
);
previewWindows.forEach((previewWindow: WindowProxy) => {
// Change the HTML file displayed by the preview window so that it starts loading
// the game.
previewWindow.location = outputDir + '/index.html';
try {
previewWindow.focus();
} catch (e) {}
// Register the window so that it can be accessed
// by the debugger and for the captures to be detected when it closes.
const debuggerId = registerNewPreviewWindow(previewWindow);
browserPreviewDebuggerServer.registerCallbacks({
onErrorReceived: () => {},
onServerStateChanged: () => {},
onConnectionClosed: async ({ id }) => {
if (id !== debuggerId) {
return;
}
if (previewOptions.captureOptions) {
await this.props.onCaptureFinished(
previewOptions.captureOptions
);
}
},
onConnectionOpened: () => {},
onConnectionErrored: () => {},
onHandleParsedMessage: () => {},
});
});
}
console.log('[BrowserSWPreviewLauncher] Preview launched successfully!');
} catch (error) {
console.error(
'[BrowserSWPreviewLauncher] Error launching preview:',
error
);
this.setState({
error,
});
}
};
getPreviewDebuggerServer() {
return browserPreviewDebuggerServer;
}
render() {
const { error } = this.state;
if (error) {
return (
<BrowserPreviewErrorDialog
error={error}
onClose={() =>
this.setState({
error: null,
})
}
/>
);
}
return null;
}
}

View File

@@ -204,7 +204,7 @@ export default class LocalPreviewLauncher extends React.Component<
// useful if the user opens the Debugger editor later, or want to
// hot reload.
return this.getPreviewDebuggerServer()
.startServer({})
.startServer()
.catch(err => {
// Ignore any error when running the debugger server - the preview
// can still work without it.

View File

@@ -91,7 +91,7 @@ export type ServerAddress = {
/** Interface to run a debugger server for previews. */
export type PreviewDebuggerServer = {|
startServer: ({ origin?: string }) => Promise<void>,
startServer: () => Promise<void>,
getServerState: () => 'started' | 'stopped',
getExistingDebuggerIds: () => Array<DebuggerId>,
sendMessage: (id: DebuggerId, message: Object) => void,

View File

@@ -1,6 +1,6 @@
// @flow
import Window from '../Utils/Window';
import { getIDEVersionWithHash } from '../Version';
import { getIDEVersion } from '../Version';
type FileSet =
| 'preview'
@@ -47,7 +47,7 @@ export const findGDJS = (
|}> => {
// Get GDJS for this version. If you updated the version,
// run `newIDE/web-app/scripts/deploy-GDJS-Runtime` script.
let gdjsRoot = `https://resources.gdevelop-app.com/GDJS-${getIDEVersionWithHash()}`;
let gdjsRoot = `https://resources.gdevelop-app.com/GDJS-${getIDEVersion()}`;
if (Window.isDev()) {
gdjsRoot = `http://localhost:5002`;

View File

@@ -20,9 +20,6 @@ export type InstancesEditorSettings = {|
/** Is the window mask shown? */
windowMask: boolean,
/** The name of the layer selected to place instances on. */
selectedLayer: string,
|};
export const getRecommendedInitialZoomFactor = (
@@ -57,7 +54,6 @@ export const prepareInstancesEditorSettings = (
0.01
),
windowMask: object.windowMask || false,
selectedLayer: object.selectedLayer || '',
};
};

View File

@@ -14,10 +14,6 @@ import {
import SceneEditor from '../../SceneEditor';
import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope';
import { type ObjectWithContext } from '../../ObjectsList/EnumerateObjects';
import {
serializeToJSObject,
unserializeFromJSObject,
} from '../../Utils/Serializer';
const gd: libGDevelop = global.gd;
@@ -125,14 +121,14 @@ export class CustomObjectEditorContainer extends React.Component<RenderEditorCon
}
saveUiSettings = () => {
const variant = this.getVariant();
const editor = this.editor;
if (editor && variant) {
unserializeFromJSObject(
variant.getAssociatedEditorSettings(),
editor.getInstancesEditorSettings()
);
}
// const layout = this.getCustomObject();
// const editor = this.editor;
// if (editor && layout) {
// unserializeFromJSObject(
// layout.getAssociatedEditorSettings(),
// editor.getInstancesEditorSettings()
// );
// }
};
getEventsFunctionsExtension(): ?gdEventsFunctionsExtension {
@@ -239,11 +235,8 @@ export class CustomObjectEditorContainer extends React.Component<RenderEditorCon
initialInstances={variant.getInitialInstances()}
getInitialInstancesEditorSettings={() =>
prepareInstancesEditorSettings(
serializeToJSObject(variant.getAssociatedEditorSettings()),
Math.max(
variant.getAreaMaxX() - variant.getAreaMinX(),
variant.getAreaMaxY() - variant.getAreaMinY()
)
{}, // TODO
1024 // TODO
)
}
onOpenEvents={() =>

View File

@@ -343,8 +343,7 @@ export const saveUiSettings = (state: EditorTabsState) => {
if (
editorTab.editorRef &&
(editorTab.editorRef instanceof SceneEditorContainer ||
editorTab.editorRef instanceof ExternalLayoutEditorContainer ||
editorTab.editorRef instanceof CustomObjectEditorContainer)
editorTab.editorRef instanceof ExternalLayoutEditorContainer)
) {
editorTab.editorRef.saveUiSettings();
}

View File

@@ -5,7 +5,6 @@ import Window from '../Utils/Window';
export type Route =
| 'onboarding' // For compatibility when there was only one tutorial.
| 'guided-lesson' // New way of opening a tutorial.
| 'signup'
| 'subscription'
| 'games-dashboard'
| 'asset-store' // For compatibility when there was only asset packs.
@@ -15,8 +14,7 @@ export type Route =
| 'education'
| 'learn'
| 'play'
| 'standalone'
| 'ask-ai';
| 'standalone';
type RouteKey =
| 'initial-dialog'
| 'game-id'
@@ -29,9 +27,7 @@ type RouteKey =
| 'course-id'
| 'create-from-example'
| 'recommended-plan-id'
| 'playable-game-id'
| 'purchase-id'
| 'claimable-token';
| 'playable-game-id';
export type RouteArguments = { [RouteKey]: string };
export type Router = {|
@@ -41,14 +37,14 @@ export type Router = {|
navigateToRoute: (route: Route, additionalArgument?: RouteArguments) => void,
|};
export const initialRouterState: Router = {
const initialState: Router = {
routeArguments: {},
removeRouteArguments: () => {},
addRouteArguments: () => {},
navigateToRoute: () => {},
};
const RouterContext = React.createContext<Router>(initialRouterState);
const RouterContext = React.createContext<Router>(initialState);
export default RouterContext;

View File

@@ -39,8 +39,6 @@ const StandaloneDialog = ({ onClose }: Props) => {
const { bundleListingDatas } = React.useContext(BundleStoreContext);
React.useEffect(
() => {
if (selectedBundleListingData) return; // We're already on a bundle page.
const bundleCategory = routeArguments['bundle-category'];
if (!bundleCategory || !bundleListingDatas) {
return;
@@ -71,17 +69,9 @@ const StandaloneDialog = ({ onClose }: Props) => {
priceCurrency: priceForUsageType && priceForUsageType.currency,
});
setSelectedBundleListingData(bundleListingData);
// Don't remove the route argument so that the user can come back to this page
// if they come back from a checkout flow.
// We do it in the onClose callback instead.
removeRouteArguments(['bundle-category']);
},
[
selectedBundleListingData,
bundleListingDatas,
routeArguments,
onClose,
removeRouteArguments,
]
[bundleListingDatas, routeArguments, onClose, removeRouteArguments]
);
return (
@@ -108,9 +98,8 @@ const StandaloneDialog = ({ onClose }: Props) => {
courses={courses}
getCourseCompletion={getCourseCompletion}
noActions
fastCheckout
onCloseAfterPurchaseDone={() => {
removeRouteArguments(['bundle-category']);
simpleCheckout
onPurchaseDone={() => {
navigateToRoute('learn', {
bundle: selectedBundleListingData.id,
});
@@ -123,10 +112,7 @@ const StandaloneDialog = ({ onClose }: Props) => {
};
const StandaloneDialogWithErrorBoundary = (props: Props) => (
<ErrorBoundary
componentTitle={<Trans>Standalone dialog</Trans>}
scope="standalone"
>
<ErrorBoundary componentTitle={<Trans>About dialog</Trans>} scope="about">
<StandaloneDialog {...props} />
</ErrorBoundary>
);

View File

@@ -194,7 +194,7 @@ import { QuickCustomizationDialog } from '../QuickCustomization/QuickCustomizati
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import useGamesList from '../GameDashboard/UseGamesList';
import useCapturesManager from './UseCapturesManager';
import useHomePageSwitch from './useHomePageSwitch';
import useOpenPageForRouting from './useOpenPageForRouting';
import RobotIcon from '../ProjectCreation/RobotIcon';
import PublicProfileContext from '../Profile/PublicProfileContext';
import { useGamesPlatformFrame } from './EditorContainers/HomePage/PlaySection/UseGamesPlatformFrame';
@@ -2098,8 +2098,9 @@ const MainFrame = (props: Props) => {
[setStandaloneDialogOpen]
);
const { navigateToRoute } = useHomePageSwitch({
const { navigateToRoute } = useOpenPageForRouting({
openHomePage,
openStandaloneDialog,
closeDialogs: closeDialogsToOpenHomePage,
});
@@ -3317,7 +3318,6 @@ const MainFrame = (props: Props) => {
openInAppTutorialDialog: selectInAppTutorial,
openProfileDialog: onOpenProfileDialog,
openAskAi,
openStandaloneDialog,
});
const onChangeProjectName = async (newName: string): Promise<void> => {

View File

@@ -14,15 +14,19 @@ const homePageRoutes: Route[] = [
'education',
];
const standaloneRoute = 'standalone';
/**
* This hook is used to be able to use route arguments from anywhere to open the homepage.
* It should close dialogs that prevent the navigation to the homepage.
*/
const useHomePageSwitch = ({
const useOpenPageForRouting = ({
openHomePage,
openStandaloneDialog,
closeDialogs,
}: {|
openHomePage: () => void,
openStandaloneDialog: () => void,
closeDialogs: () => void,
|}) => {
const {
@@ -41,12 +45,24 @@ const useHomePageSwitch = ({
closeDialogs();
openHomePage();
}
if (initialDialog === standaloneRoute) {
closeDialogs();
openStandaloneDialog();
removeRouteArguments(['initial-dialog']);
}
},
[routeArguments, openHomePage, closeDialogs, removeRouteArguments]
[
routeArguments,
openHomePage,
closeDialogs,
openStandaloneDialog,
removeRouteArguments,
]
);
return {
navigateToRoute,
};
};
export default useHomePageSwitch;
export default useOpenPageForRouting;

View File

@@ -30,7 +30,6 @@ import {
type Bundle,
} from '../Utils/GDevelopServices/Asset';
import { type Purchase } from '../Utils/GDevelopServices/Shop';
import { type ClaimedProductOptions } from './PurchaseClaimDialog';
export type AuthenticatedUser = {|
authenticated: boolean,
@@ -70,18 +69,9 @@ export type AuthenticatedUser = {|
) => Promise<void>,
onResetPassword: ForgotPasswordForm => Promise<void>,
onOpenLoginDialog: () => void,
onOpenLoginWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => void,
onOpenEditProfileDialog: () => void,
onOpenChangeEmailDialog: () => void,
onOpenCreateAccountDialog: () => void,
onOpenCreateAccountWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => void,
onOpenPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => void,
onBadgesChanged: () => Promise<void>,
onCloudProjectsChanged: () => Promise<void>,
onRefreshUserProfile: () => Promise<void>,
@@ -141,12 +131,9 @@ export const initialAuthenticatedUser = {
onEditProfile: async () => {},
onResetPassword: async () => {},
onOpenLoginDialog: () => {},
onOpenLoginWithPurchaseClaimDialog: () => {},
onOpenEditProfileDialog: () => {},
onOpenChangeEmailDialog: () => {},
onOpenCreateAccountDialog: () => {},
onOpenCreateAccountWithPurchaseClaimDialog: () => {},
onOpenPurchaseClaimDialog: () => {},
onBadgesChanged: async () => {},
onCloudProjectsChanged: async () => {},
onRefreshUserProfile: async () => {},

View File

@@ -63,11 +63,6 @@ import { showErrorBox } from '../UI/Messages/MessageBox';
import { userCancellationErrorName } from '../LoginProvider/Utils';
import { listUserPurchases } from '../Utils/GDevelopServices/Shop';
import { listNotifications } from '../Utils/GDevelopServices/Notification';
import LoginWithPurchaseClaimDialog from './LoginWithPurchaseClaimDialog';
import CreateAccountWithPurchaseClaimDialog from './CreateAccountWithPurchaseClaimDialog';
import PurchaseClaimDialog, {
type ClaimedProductOptions,
} from './PurchaseClaimDialog';
type Props = {|
authentication: Authentication,
@@ -78,9 +73,7 @@ type Props = {|
type State = {|
authenticatedUser: AuthenticatedUser,
loginDialogOpen: boolean,
loginWithPurchaseClaimDialogOpen: boolean,
createAccountDialogOpen: boolean,
createAccountWithPurchaseClaimDialogOpen: boolean,
loginInProgress: boolean,
createAccountInProgress: boolean,
editProfileDialogOpen: boolean,
@@ -97,7 +90,6 @@ type State = {|
changeEmailDialogOpen: boolean,
changeEmailInProgress: boolean,
userSnackbarMessage: ?React.Node,
claimedProductOptions: ?ClaimedProductOptions,
|};
const cleanUserTracesOnDevice = async () => {
@@ -117,9 +109,7 @@ export default class AuthenticatedUserProvider extends React.Component<
state = {
authenticatedUser: initialAuthenticatedUser,
loginDialogOpen: false,
loginWithPurchaseClaimDialogOpen: false,
createAccountDialogOpen: false,
createAccountWithPurchaseClaimDialogOpen: false,
loginInProgress: false,
createAccountInProgress: false,
editProfileDialogOpen: false,
@@ -136,7 +126,6 @@ export default class AuthenticatedUserProvider extends React.Component<
changeEmailDialogOpen: false,
changeEmailInProgress: false,
userSnackbarMessage: null,
claimedProductOptions: null,
};
_automaticallyUpdateUserProfile = true;
_hasNotifiedUserAboutEmailVerification = false;
@@ -221,22 +210,9 @@ export default class AuthenticatedUserProvider extends React.Component<
onBadgesChanged: this._fetchUserBadges,
onCloudProjectsChanged: this._fetchUserCloudProjects,
onOpenLoginDialog: () => this.openLoginDialog(true),
onOpenLoginWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => this.openLoginWithPurchaseClaimDialog(true, claimedProductOptions),
onOpenEditProfileDialog: () => this.openEditProfileDialog(true),
onOpenChangeEmailDialog: () => this.openChangeEmailDialog(true),
onOpenCreateAccountDialog: () => this.openCreateAccountDialog(true),
onOpenCreateAccountWithPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) =>
this.openCreateAccountWithPurchaseClaimDialog(
true,
claimedProductOptions
),
onOpenPurchaseClaimDialog: (
claimedProductOptions: ClaimedProductOptions
) => this.openPurchaseClaimDialog(claimedProductOptions),
onRefreshUserProfile: this._fetchUserProfile,
onRefreshFirebaseProfile: async () => {
await this._reloadFirebaseProfile();
@@ -1156,15 +1132,7 @@ export default class AuthenticatedUserProvider extends React.Component<
});
await this._fetchUserProfileWithoutThrowingErrors({ resetState: true });
this.openLoginDialog(false);
this.openLoginWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
this.openCreateAccountDialog(false);
this.openCreateAccountWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
this._showLoginSnackbar(this.state.authenticatedUser);
} catch (apiCallError) {
if (apiCallError.name !== userCancellationErrorName) {
@@ -1219,10 +1187,6 @@ export default class AuthenticatedUserProvider extends React.Component<
await authentication.login(form);
await this._fetchUserProfileWithoutThrowingErrors({ resetState: true });
this.openLoginDialog(false);
this.openLoginWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
this._showLoginSnackbar(this.state.authenticatedUser);
} catch (apiCallError) {
this.setState({
@@ -1325,10 +1289,6 @@ export default class AuthenticatedUserProvider extends React.Component<
resetState: true,
});
this.openCreateAccountDialog(false);
this.openCreateAccountWithPurchaseClaimDialog(
false,
this.state.claimedProductOptions
);
sendSignupDone(form.email);
const firebaseUser = this.state.authenticatedUser.firebaseUser;
aliasUserForAnalyticsAfterSignUp(firebaseUser);
@@ -1492,18 +1452,6 @@ export default class AuthenticatedUserProvider extends React.Component<
});
};
openLoginWithPurchaseClaimDialog = (
open: boolean = true,
claimedProductOptions: ?ClaimedProductOptions = null
) => {
this.setState({
loginWithPurchaseClaimDialogOpen: open,
createAccountWithPurchaseClaimDialogOpen: false,
apiCallError: null,
claimedProductOptions,
});
};
showUserSnackbar = ({ message }: {| message: ?React.Node |}) => {
this.setState({
// The message is wrapped here to prevent crashes when Google Translate
@@ -1527,26 +1475,6 @@ export default class AuthenticatedUserProvider extends React.Component<
});
};
openCreateAccountWithPurchaseClaimDialog = (
open: boolean = true,
claimedProductOptions: ?ClaimedProductOptions = null
) => {
this.setState({
loginWithPurchaseClaimDialogOpen: false,
createAccountWithPurchaseClaimDialogOpen: open,
apiCallError: null,
claimedProductOptions,
});
};
openPurchaseClaimDialog = (
claimedProductOptions: ?ClaimedProductOptions = null
) => {
this.setState({
claimedProductOptions,
});
};
openChangeEmailDialog = (open: boolean = true) => {
this.setState({
changeEmailDialogOpen: open,
@@ -1686,33 +1614,13 @@ export default class AuthenticatedUserProvider extends React.Component<
}}
onGoToCreateAccount={() => this.openCreateAccountDialog(true)}
onLogin={this._doLogin}
onLogout={this._doLogout}
onLoginWithProvider={this._doLoginWithProvider}
loginInProgress={this.state.loginInProgress}
error={this.state.apiCallError}
onForgotPassword={this._doForgotPassword}
/>
)}
{this.state.loginWithPurchaseClaimDialogOpen &&
this.state.claimedProductOptions && (
<LoginWithPurchaseClaimDialog
onClose={() => {
this._cancelLoginOrSignUp();
this.openLoginWithPurchaseClaimDialog(false);
}}
onGoToCreateAccount={() =>
this.openCreateAccountWithPurchaseClaimDialog(
true,
this.state.claimedProductOptions
)
}
onLogin={this._doLogin}
onLoginWithProvider={this._doLoginWithProvider}
loginInProgress={this.state.loginInProgress}
error={this.state.apiCallError}
onForgotPassword={this._doForgotPassword}
claimedProductOptions={this.state.claimedProductOptions}
/>
)}
{this.state.authenticatedUser.profile &&
this.state.editProfileDialogOpen && (
<EditProfileDialog
@@ -1786,28 +1694,6 @@ export default class AuthenticatedUserProvider extends React.Component<
error={this.state.apiCallError}
/>
)}
{this.state.createAccountWithPurchaseClaimDialogOpen &&
this.state.claimedProductOptions && (
<CreateAccountWithPurchaseClaimDialog
onClose={() => {
this._cancelLoginOrSignUp();
this.openCreateAccountWithPurchaseClaimDialog(false);
}}
onGoToLogin={() =>
this.openLoginWithPurchaseClaimDialog(
true,
this.state.claimedProductOptions
)
}
onCreateAccount={form =>
this._doCreateAccount(form, this.props.preferencesValues)
}
onLoginWithProvider={this._doLoginWithProvider}
createAccountInProgress={this.state.createAccountInProgress}
error={this.state.apiCallError}
claimedProductOptions={this.state.claimedProductOptions}
/>
)}
{this.state.emailVerificationDialogOpen && (
<EmailVerificationDialog
authenticatedUser={this.state.authenticatedUser}
@@ -1825,12 +1711,6 @@ export default class AuthenticatedUserProvider extends React.Component<
onSendEmail={this._doSendEmailVerification}
/>
)}
{this.state.claimedProductOptions && (
<PurchaseClaimDialog
claimedProductOptions={this.state.claimedProductOptions}
onClose={() => this.openPurchaseClaimDialog(null)}
/>
)}
<Snackbar
open={!!this.state.userSnackbarMessage}
autoHideDuration={3000}

View File

@@ -1,5 +1,5 @@
// @flow
import * as React from 'react';
import React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../UI/FlatButton';
@@ -189,18 +189,10 @@ const CreateAccountDialog = ({
justifyContent="center"
alignItems="center"
>
{
<ColumnStackLayout
noMargin
justifyContent="center"
alignItems="center"
>
<GDevelopGLogo fontSize="large" />
<Text size="section-title" align="center" noMargin>
<Trans>Welcome to GDevelop!</Trans>
</Text>
</ColumnStackLayout>
}
<GDevelopGLogo fontSize="large" />
<Text size="section-title" align="center" noMargin>
<Trans>Welcome to GDevelop!</Trans>
</Text>
<div style={styles.formContainer}>
<CreateAccountForm
onCreateAccount={createAccount}

View File

@@ -1,185 +0,0 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import FlatButton from '../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import {
type RegisterForm,
type AuthError,
type IdentityProvider,
} from '../Utils/GDevelopServices/Authentication';
import { type UsernameAvailability } from '../Utils/GDevelopServices/User';
import LeftLoader from '../UI/LeftLoader';
import { ColumnStackLayout } from '../UI/Layout';
import { isUsernameValid } from './UsernameField';
import HelpButton from '../UI/HelpButton';
import Text from '../UI/Text';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import CreateAccountForm from './CreateAccountForm';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { type ClaimedProductOptions } from './PurchaseClaimDialog';
const getStyles = ({ isMobile }) => {
return {
formContainer: {
display: 'flex',
width: isMobile ? '95%' : '90%',
marginTop: 10,
flexDirection: 'column',
},
previewImage: {
width: '100%',
maxWidth: 400,
display: 'block',
objectFit: 'contain',
borderRadius: 8,
border: '1px solid lightgrey',
boxSizing: 'border-box', // Take border in account for sizing to avoid cumulative layout shift.
// Prevent cumulative layout shift by enforcing
// the 16:9 ratio.
aspectRatio: '16 / 9',
transition: 'opacity 0.3s ease-in-out',
position: 'relative',
},
};
};
type Props = {|
onClose: () => void,
onGoToLogin: () => void,
onCreateAccount: (form: RegisterForm) => Promise<void>,
onLoginWithProvider: (provider: IdentityProvider) => Promise<void>,
createAccountInProgress: boolean,
error: ?AuthError,
claimedProductOptions: ClaimedProductOptions,
|};
const CreateAccountWithPurchaseClaimDialog = ({
onClose,
onGoToLogin,
onCreateAccount,
onLoginWithProvider,
createAccountInProgress,
error,
claimedProductOptions: { productListingData: claimedProduct },
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const styles = getStyles({ isMobile });
const [email, setEmail] = React.useState<string>('');
const [password, setPassword] = React.useState<string>('');
const [username, setUsername] = React.useState<string>('');
const [getNewsletterEmail, setGetNewsletterEmail] = React.useState<boolean>(
false
);
const [
usernameAvailability,
setUsernameAvailability,
] = React.useState<?UsernameAvailability>(null);
const [
isValidatingUsername,
setIsValidatingUsername,
] = React.useState<boolean>(false);
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const canCreateAccount =
!createAccountInProgress &&
isUsernameValid(username, { allowEmpty: true }) &&
!isValidatingUsername &&
(!usernameAvailability || usernameAvailability.isAvailable);
const createAccount = async () => {
if (!canCreateAccount) return;
try {
await onCreateAccount({
email: email.trim(),
password,
username,
getNewsletterEmail,
});
} catch (error) {
console.error('Error while creating account', error);
}
};
return (
<Dialog
title={null} // This dialog has a custom design to be more welcoming, the title is set in the content.
id="create-account-with-purchase-claim-dialog"
actions={[
<FlatButton
label={<Trans>Cancel</Trans>}
key="close"
primary={false}
onClick={onClose}
/>,
<LeftLoader isLoading={createAccountInProgress} key="create-account">
<DialogPrimaryButton
label={<Trans>Create account</Trans>}
id="create-account-button"
primary
disabled={!canCreateAccount}
onClick={createAccount}
/>
</LeftLoader>,
]}
secondaryActions={[
<HelpButton key="help" helpPagePath={'/interface/profile'} />,
]}
cannotBeDismissed={createAccountInProgress}
onApply={createAccount}
onRequestClose={() => {
if (!createAccountInProgress) onClose();
}}
maxWidth="md"
open
flexColumnBody
>
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<ColumnStackLayout justifyContent="center" alignItems="center" noMargin>
{claimedProduct.productType === 'BUNDLE' && (
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={claimedProduct.thumbnailUrls[0]}
alt={`Preview image of bundle ${claimedProduct.name}`}
/>
)}
<Text size="section-title" align="center" noMargin>
<Trans>Create an account to activate your purchase!</Trans>
</Text>
</ColumnStackLayout>
<div style={styles.formContainer}>
<CreateAccountForm
onCreateAccount={createAccount}
onLoginWithProvider={onLoginWithProvider}
email={email}
onChangeEmail={setEmail}
password={password}
onChangePassword={setPassword}
username={username}
onChangeUsername={setUsername}
optInNewsletterEmail={getNewsletterEmail}
onChangeOptInNewsletterEmail={setGetNewsletterEmail}
createAccountInProgress={createAccountInProgress}
error={error}
onChangeUsernameAvailability={setUsernameAvailability}
isValidatingUsername={isValidatingUsername}
onChangeIsValidatingUsername={setIsValidatingUsername}
onGoToLogin={onGoToLogin}
/>
</div>
</ColumnStackLayout>
</Dialog>
);
};
export default CreateAccountWithPurchaseClaimDialog;

View File

@@ -1,7 +1,7 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import React from 'react';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import {
type LoginForm as LoginFormType,
@@ -33,6 +33,7 @@ type Props = {|
onClose: () => void,
onGoToCreateAccount: () => void,
onLogin: (form: LoginFormType) => Promise<void>,
onLogout: () => Promise<void>,
onLoginWithProvider: (provider: IdentityProvider) => Promise<void>,
onForgotPassword: (form: ForgotPasswordForm) => Promise<void>,
loginInProgress: boolean,
@@ -43,6 +44,7 @@ const LoginDialog = ({
onClose,
onGoToCreateAccount,
onLogin,
onLogout,
onLoginWithProvider,
onForgotPassword,
loginInProgress,
@@ -91,14 +93,10 @@ const LoginDialog = ({
justifyContent="center"
alignItems="center"
>
{
<ColumnStackLayout noMargin justifyContent="center" alignItems="center">
<GDevelopGLogo fontSize="large" />
<Text noMargin size="section-title" align="center">
<Trans>Log in to your account</Trans>
</Text>
</ColumnStackLayout>
}
<GDevelopGLogo fontSize="large" />
<Text noMargin size="section-title" align="center">
<Trans>Log in to your account</Trans>
</Text>
<div style={styles.formContainer}>
<LoginForm
onLogin={doLogin}

View File

@@ -1,163 +0,0 @@
// @flow
import { Trans } from '@lingui/macro';
import * as React from 'react';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import {
type LoginForm as LoginFormType,
type ForgotPasswordForm,
type AuthError,
type IdentityProvider,
} from '../Utils/GDevelopServices/Authentication';
import LoginForm from './LoginForm';
import LeftLoader from '../UI/LeftLoader';
import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout';
import HelpButton from '../UI/HelpButton';
import FlatButton from '../UI/FlatButton';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
import { CorsAwareImage } from '../UI/CorsAwareImage';
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
import { type ClaimedProductOptions } from './PurchaseClaimDialog';
const getStyles = ({ isMobile }) => {
return {
formContainer: {
display: 'flex',
width: isMobile ? '95%' : '90%',
marginTop: 10,
flexDirection: 'column',
},
previewImage: {
width: '100%',
maxWidth: 400,
display: 'block',
objectFit: 'contain',
borderRadius: 8,
border: '1px solid lightgrey',
boxSizing: 'border-box', // Take border in account for sizing to avoid cumulative layout shift.
// Prevent cumulative layout shift by enforcing
// the 16:9 ratio.
aspectRatio: '16 / 9',
transition: 'opacity 0.3s ease-in-out',
position: 'relative',
},
};
};
type Props = {|
onClose: () => void,
onGoToCreateAccount: () => void,
onLogin: (form: LoginFormType) => Promise<void>,
onLoginWithProvider: (provider: IdentityProvider) => Promise<void>,
onForgotPassword: (form: ForgotPasswordForm) => Promise<void>,
loginInProgress: boolean,
error: ?AuthError,
claimedProductOptions: ClaimedProductOptions,
|};
const LoginWithPurchaseClaimDialog = ({
onClose,
onGoToCreateAccount,
onLogin,
onLoginWithProvider,
onForgotPassword,
loginInProgress,
error,
claimedProductOptions: { productListingData: claimedProduct },
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const styles = getStyles({ isMobile });
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const doLogin = () => {
if (loginInProgress) return;
onLogin({
email: email.trim(),
password,
});
};
const actions = [
<FlatButton
label={<Trans>Cancel</Trans>}
key="cancel"
primary={false}
onClick={onClose}
/>,
<LeftLoader isLoading={loginInProgress} key="login">
<DialogPrimaryButton
id="login-button"
label={<Trans>Login</Trans>}
primary
onClick={doLogin}
disabled={loginInProgress}
/>
</LeftLoader>,
];
const secondaryActions = [
<HelpButton key="help" helpPagePath={'/interface/profile'} />,
];
const dialogContent = (
<ColumnStackLayout
noMargin
expand
justifyContent="center"
alignItems="center"
>
<ColumnStackLayout justifyContent="center" alignItems="center" noMargin>
{claimedProduct.productType === 'BUNDLE' && (
<CorsAwareImage
style={{
...styles.previewImage,
background: gdevelopTheme.paper.backgroundColor.light,
}}
src={claimedProduct.thumbnailUrls[0]}
alt={`Preview image of bundle ${claimedProduct.name}`}
/>
)}
<Text size="section-title" align="center" noMargin>
<Trans>Log in to your account to activate your purchase!</Trans>
</Text>
</ColumnStackLayout>
<div style={styles.formContainer}>
<LoginForm
onLogin={doLogin}
onLoginWithProvider={onLoginWithProvider}
email={email}
onChangeEmail={setEmail}
password={password}
onChangePassword={setPassword}
onForgotPassword={onForgotPassword}
loginInProgress={loginInProgress}
error={error}
onGoToCreateAccount={onGoToCreateAccount}
/>
</div>
</ColumnStackLayout>
);
return (
<Dialog
title={null} // This dialog has a custom design to be more welcoming, the title is set in the content.
id="login-dialog"
actions={actions}
secondaryActions={secondaryActions}
cannotBeDismissed={loginInProgress}
onRequestClose={onClose}
onApply={doLogin}
maxWidth="md"
open
flexColumnBody
>
{dialogContent}
</Dialog>
);
};
export default LoginWithPurchaseClaimDialog;

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