Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
1ec6ea68fe Add line height support for Text and BBText objects
Co-authored-by: florian <florian@gdevelop.io>
2025-08-07 14:17:51 +00:00
243 changed files with 4415 additions and 7810 deletions

View File

@@ -18,19 +18,19 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsAnimatableExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("AnimatableCapability",
_("Objects with animations"),
_("Actions and conditions for objects having animations (sprite, 3D models...)."),
_("Animate objects."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Objects with animations"))
extension.AddInstructionOrExpressionGroupMetadata(_("Animatable capability"))
.SetIcon("res/actions/animation24.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Animations and images"))
.SetIcon("res/actions/animation24.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"AnimatableBehavior",
_("Objects with animations"),
_("Animatable capability"),
"Animation",
_("Actions and conditions for objects having animations (sprite, 3D models...).."),
"",

View File

@@ -18,7 +18,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsEffectExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("EffectCapability",
_("Objects with effects"),
_("Effect capability"),
_("Actions/conditions to enable/disable and change parameters of visual effects applied on objects."),
"Florian Rival",
"Open source (MIT License)")
@@ -28,7 +28,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsEffectExtension(
gd::BehaviorMetadata& aut = extension.AddBehavior(
"EffectBehavior",
_("Objects with effects"),
_("Effect capability"),
"Effect",
_("Actions/conditions to enable/disable and change parameters of visual effects applied on objects."),
"",

View File

@@ -18,7 +18,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsFlippableExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("FlippableCapability",
_("Flippable objects"),
_("Flippable capability"),
_("Actions/conditions for objects which can be flipped horizontally or vertically."),
"Florian Rival",
"Open source (MIT License)")
@@ -28,7 +28,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsFlippableExtension(
gd::BehaviorMetadata& aut = extension.AddBehavior(
"FlippableBehavior",
_("Flippable objects"),
_("Flippable capability"),
"Flippable",
_("Actions/conditions for objects which can be flipped horizontally or vertically."),
"",

View File

@@ -18,13 +18,13 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsOpacityExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("OpacityCapability",
_("Objects with opacity"),
_("Opacity capability"),
_("Action/condition/expression to change or "
"check the opacity of an object (0-255)."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Objects with opacity"))
extension.AddInstructionOrExpressionGroupMetadata(_("Opacity capability"))
.SetIcon("res/actions/opacity24.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Visibility"))
.SetIcon("res/actions/opacity24.png");
@@ -32,7 +32,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsOpacityExtension(
gd::BehaviorMetadata& aut =
extension
.AddBehavior("OpacityBehavior",
_("Objects with opacity"),
_("Opacity capability"),
"Opacity",
_("Action/condition/expression to change or check the "
"opacity of an object (0-255)."),

View File

@@ -18,7 +18,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsResizableExtension(
extension
.SetExtensionInformation(
"ResizableCapability",
_("Resizable objects"),
_("Resizable capability"),
_("Change or compare the size (width/height) of an object which can "
"be resized (i.e: most objects)."),
"Florian Rival",
@@ -30,7 +30,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsResizableExtension(
gd::BehaviorMetadata &aut =
extension
.AddBehavior("ResizableBehavior",
_("Resizable objects"),
_("Resizable capability"),
"Resizable",
_("Change or compare the size (width/height) of an "
"object which can be resized (i.e: most objects)."),

View File

@@ -18,13 +18,13 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsScalableExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("ScalableCapability",
_("Scalable objects"),
_("Scalable capability"),
_("Actions/conditions/expression to change or "
"check the scale of an object (default: 1)."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Scalable objects"))
extension.AddInstructionOrExpressionGroupMetadata(_("Scalable capability"))
.SetIcon("res/actions/scale24_black.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Size")).SetIcon(
"res/actions/scale24_black.png");
@@ -32,7 +32,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsScalableExtension(
gd::BehaviorMetadata& aut =
extension
.AddBehavior("ScalableBehavior",
_("Scalable objects"),
_("Scalable capability"),
"Scale",
_("Actions/conditions/expression to change or check the "
"scale of an object (default: 1)."),

View File

@@ -18,17 +18,17 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsTextContainerExtension(
gd::PlatformExtension& extension) {
extension
.SetExtensionInformation("TextContainerCapability",
_("Objects containing a text"),
_("Text capability"),
_("Allows an object to contain a text, usually shown on screen, that can be modified."),
"Florian Rival",
"Open source (MIT License)")
.SetExtensionHelpPath("/objects");
extension.AddInstructionOrExpressionGroupMetadata(_("Objects containing a text"))
extension.AddInstructionOrExpressionGroupMetadata(_("Text capability"))
.SetIcon("res/conditions/text24_black.png");
gd::BehaviorMetadata& aut = extension.AddBehavior(
"TextContainerBehavior",
_("Objects containing a text"),
_("Text capability"),
"Text",
_("Allows an object to contain a text, usually shown on screen, that can be modified."),
"",

View File

@@ -52,25 +52,10 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsKeyboardExtension(
.SetHidden();
extension
.AddCondition(
"KeyFromTextPressed",
_("Key pressed"),
_("Check if a key is pressed. This stays true as long as "
"the key is held down. To check if a key was pressed during "
"the frame, use \"Key just pressed\" instead."),
_("_PARAM1_ key is pressed"),
"",
"res/conditions/keyboard24.png",
"res/conditions/keyboard.png")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("keyboardKey", _("Key to check"))
.MarkAsSimple();
extension
.AddCondition("KeyFromTextJustPressed",
_("Key just pressed"),
_("Check if a key was just pressed."),
_("_PARAM1_ key was just pressed"),
.AddCondition("KeyFromTextPressed",
_("Key pressed"),
_("Check if a key is pressed"),
_("_PARAM1_ key is pressed"),
"",
"res/conditions/keyboard24.png",
"res/conditions/keyboard.png")
@@ -81,7 +66,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsKeyboardExtension(
extension
.AddCondition("KeyFromTextReleased",
_("Key released"),
_("Check if a key was just released."),
_("Check if a key was just released"),
_("_PARAM1_ key is released"),
"",
"res/conditions/keyboard24.png",

View File

@@ -298,19 +298,6 @@ class GD_CORE_API BehaviorMetadata : public InstructionOrExpressionContainerMeta
return *this;
}
/**
* Check if the behavior can be used on objects from event-based objects.
*/
bool IsRelevantForChildObjects() const { return isRelevantForChildObjects; }
/**
* Set that behavior can't be used on objects from event-based objects.
*/
BehaviorMetadata &MarkAsIrrelevantForChildObjects() {
isRelevantForChildObjects = false;
return *this;
}
QuickCustomization::Visibility GetQuickCustomizationVisibility() const {
return quickCustomizationVisibility;
}
@@ -406,7 +393,6 @@ class GD_CORE_API BehaviorMetadata : public InstructionOrExpressionContainerMeta
mutable std::vector<gd::String> requiredBehaviors;
bool isPrivate = false;
bool isHidden = false;
bool isRelevantForChildObjects = true;
gd::String openFullEditorLabel;
QuickCustomization::Visibility quickCustomizationVisibility = QuickCustomization::Visibility::Default;

View File

@@ -277,10 +277,6 @@ class GD_CORE_API MetadataProvider {
return &metadata == &badObjectInfo;
}
static bool IsBadEffectMetadata(const gd::EffectMetadata& metadata) {
return &metadata == &badEffectMetadata;
}
virtual ~MetadataProvider();
private:

View File

@@ -75,17 +75,6 @@ void ResourceExposer::ExposeProjectResources(
// Expose global objects configuration resources
auto objectWorker = gd::GetResourceWorkerOnObjects(project, worker);
objectWorker.Launch(project.GetObjects());
// Exposed extension event resources
// Note that using resources in extensions is very unlikely and probably not
// worth the effort of something smart.
auto eventWorker = gd::GetResourceWorkerOnEvents(project, worker);
for (std::size_t e = 0; e < project.GetEventsFunctionsExtensionsCount();
e++) {
auto &eventsFunctionsExtension = project.GetEventsFunctionsExtension(e);
gd::ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(
project, eventsFunctionsExtension, eventWorker);
}
}
void ResourceExposer::ExposeLayoutResources(
@@ -114,6 +103,16 @@ void ResourceExposer::ExposeLayoutResources(
auto eventWorker = gd::GetResourceWorkerOnEvents(project, worker);
gd::ProjectBrowserHelper::ExposeLayoutEventsAndDependencies(
project, layout, eventWorker);
// Exposed extension event resources
// Note that using resources in extensions is very unlikely and probably not
// worth the effort of something smart.
for (std::size_t e = 0; e < project.GetEventsFunctionsExtensionsCount();
e++) {
auto &eventsFunctionsExtension = project.GetEventsFunctionsExtension(e);
gd::ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(
project, eventsFunctionsExtension, eventWorker);
}
}
void ResourceExposer::ExposeEffectResources(

View File

@@ -764,6 +764,129 @@ TEST_CASE("ArbitraryResourceWorker", "[common][resources]") {
REQUIRE(worker.audios[0] == "res4");
}
SECTION("Can find resource usages in event-based functions") {
gd::Project project;
gd::Platform platform;
SetupProjectWithDummyPlatform(project, platform);
project.GetResourcesManager().AddResource(
"res1", "path/to/file1.png", "image");
project.GetResourcesManager().AddResource(
"res2", "path/to/file2.png", "image");
project.GetResourcesManager().AddResource(
"res3", "path/to/file3.png", "image");
ArbitraryResourceWorkerTest worker(project.GetResourcesManager());
auto& extension = project.InsertNewEventsFunctionsExtension("MyEventExtension", 0);
auto &function = extension.GetEventsFunctions().InsertNewEventsFunction(
"MyFreeFunction", 0);
gd::StandardEvent standardEvent;
gd::Instruction instruction;
instruction.SetType("MyExtension::DoSomethingWithResources");
instruction.SetParametersCount(3);
instruction.SetParameter(0, "res3");
instruction.SetParameter(1, "res1");
instruction.SetParameter(2, "res4");
standardEvent.GetActions().Insert(instruction);
function.GetEvents().InsertEvent(standardEvent);
auto& layout = project.InsertNewLayout("MyScene", 0);
// MyEventExtension::MyFreeFunction doesn't need to be actually used in
// events because the implementation is naive.
gd::ResourceExposer::ExposeLayoutResources(project, layout, worker);
REQUIRE(worker.bitmapFonts.size() == 1);
REQUIRE(worker.bitmapFonts[0] == "res3");
REQUIRE(worker.images.size() == 1);
REQUIRE(worker.images[0] == "res1");
REQUIRE(worker.audios.size() == 1);
REQUIRE(worker.audios[0] == "res4");
}
SECTION("Can find resource usages in event-based behavior functions") {
gd::Project project;
gd::Platform platform;
SetupProjectWithDummyPlatform(project, platform);
project.GetResourcesManager().AddResource(
"res1", "path/to/file1.png", "image");
project.GetResourcesManager().AddResource(
"res2", "path/to/file2.png", "image");
project.GetResourcesManager().AddResource(
"res3", "path/to/file3.png", "image");
ArbitraryResourceWorkerTest worker(project.GetResourcesManager());
auto& extension = project.InsertNewEventsFunctionsExtension("MyEventExtension", 0);
auto& behavior = extension.GetEventsBasedBehaviors().InsertNew("MyBehavior", 0);
auto& function = behavior.GetEventsFunctions().InsertNewEventsFunction("MyFunction", 0);
gd::StandardEvent standardEvent;
gd::Instruction instruction;
instruction.SetType("MyExtension::DoSomethingWithResources");
instruction.SetParametersCount(3);
instruction.SetParameter(0, "res3");
instruction.SetParameter(1, "res1");
instruction.SetParameter(2, "res4");
standardEvent.GetActions().Insert(instruction);
function.GetEvents().InsertEvent(standardEvent);
auto& layout = project.InsertNewLayout("MyScene", 0);
// MyEventExtension::MyBehavior::MyFunction doesn't need to be actually used in
// events because the implementation is naive.
gd::ResourceExposer::ExposeLayoutResources(project, layout, worker);
REQUIRE(worker.bitmapFonts.size() == 1);
REQUIRE(worker.bitmapFonts[0] == "res3");
REQUIRE(worker.images.size() == 1);
REQUIRE(worker.images[0] == "res1");
REQUIRE(worker.audios.size() == 1);
REQUIRE(worker.audios[0] == "res4");
}
SECTION("Can find resource usages in event-based object functions") {
gd::Project project;
gd::Platform platform;
SetupProjectWithDummyPlatform(project, platform);
project.GetResourcesManager().AddResource(
"res1", "path/to/file1.png", "image");
project.GetResourcesManager().AddResource(
"res2", "path/to/file2.png", "image");
project.GetResourcesManager().AddResource(
"res3", "path/to/file3.png", "image");
ArbitraryResourceWorkerTest worker(project.GetResourcesManager());
auto& extension = project.InsertNewEventsFunctionsExtension("MyEventExtension", 0);
auto& object = extension.GetEventsBasedObjects().InsertNew("MyObject", 0);
auto& function = object.GetEventsFunctions().InsertNewEventsFunction("MyFunction", 0);
gd::StandardEvent standardEvent;
gd::Instruction instruction;
instruction.SetType("MyExtension::DoSomethingWithResources");
instruction.SetParametersCount(3);
instruction.SetParameter(0, "res3");
instruction.SetParameter(1, "res1");
instruction.SetParameter(2, "res4");
standardEvent.GetActions().Insert(instruction);
function.GetEvents().InsertEvent(standardEvent);
auto& layout = project.InsertNewLayout("MyScene", 0);
// MyEventExtension::MyObject::MyFunction doesn't need to be actually used in
// events because the implementation is naive.
gd::ResourceExposer::ExposeLayoutResources(project, layout, worker);
REQUIRE(worker.bitmapFonts.size() == 1);
REQUIRE(worker.bitmapFonts[0] == "res3");
REQUIRE(worker.images.size() == 1);
REQUIRE(worker.images[0] == "res1");
REQUIRE(worker.audios.size() == 1);
REQUIRE(worker.audios[0] == "res4");
}
SECTION("Can find resource usages in layer effects") {
gd::Project project;
gd::Platform platform;

View File

@@ -162,12 +162,7 @@ module.exports = {
)
.addParameter('object', _('3D object'), '', false)
.addParameter('behavior', _('Behavior'), 'Base3DBehavior')
.useStandardParameters(
'number',
gd.ParameterOptions.makeNewOptions().setDescription(
_('Angle (in degrees)')
)
)
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setRotationX')
.setGetter('getRotationX');
@@ -183,12 +178,7 @@ module.exports = {
)
.addParameter('object', _('3D object'), '', false)
.addParameter('behavior', _('Behavior'), 'Base3DBehavior')
.useStandardParameters(
'number',
gd.ParameterOptions.makeNewOptions().setDescription(
_('Angle (in degrees)')
)
)
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setRotationY')
.setGetter('getRotationY');
@@ -206,7 +196,7 @@ module.exports = {
)
.addParameter('object', _('3D object'), '', false)
.addParameter('behavior', _('Behavior'), 'Base3DBehavior')
.addParameter('number', _('Angle to add (in degrees)'), '', false)
.addParameter('number', _('Rotation angle'), '', false)
.markAsAdvanced()
.setFunctionName('turnAroundX');
@@ -224,7 +214,7 @@ module.exports = {
)
.addParameter('object', _('3D object'), '', false)
.addParameter('behavior', _('Behavior'), 'Base3DBehavior')
.addParameter('number', _('Angle to add (in degrees)'), '', false)
.addParameter('number', _('Rotation angle'), '', false)
.markAsAdvanced()
.setFunctionName('turnAroundY');
@@ -242,7 +232,7 @@ module.exports = {
)
.addParameter('object', _('3D object'), '', false)
.addParameter('behavior', _('Behavior'), 'Base3DBehavior')
.addParameter('number', _('Angle to add (in degrees)'), '', false)
.addParameter('number', _('Rotation angle'), '', false)
.markAsAdvanced()
.setFunctionName('turnAroundZ');
}
@@ -604,12 +594,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D model'), 'Model3DObject', false)
.useStandardParameters(
'number',
gd.ParameterOptions.makeNewOptions().setDescription(
_('Angle (in degrees)')
)
)
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setHidden()
.setFunctionName('setRotationX')
.setGetter('getRotationX');
@@ -626,12 +611,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D model'), 'Model3DObject', false)
.useStandardParameters(
'number',
gd.ParameterOptions.makeNewOptions().setDescription(
_('Angle (in degrees)')
)
)
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setHidden()
.setFunctionName('setRotationY')
.setGetter('getRotationY');
@@ -650,7 +630,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D model'), 'Model3DObject', false)
.addParameter('number', _('Angle to add (in degrees)'), '', false)
.addParameter('number', _('Rotation angle'), '', false)
.markAsAdvanced()
.setHidden()
.setFunctionName('turnAroundX');
@@ -669,7 +649,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D model'), 'Model3DObject', false)
.addParameter('number', _('Angle to add (in degrees)'), '', false)
.addParameter('number', _('Rotation angle'), '', false)
.markAsAdvanced()
.setHidden()
.setFunctionName('turnAroundY');
@@ -688,7 +668,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D model'), 'Model3DObject', false)
.addParameter('number', _('Angle to add (in degrees)'), '', false)
.addParameter('number', _('Rotation angle'), '', false)
.markAsAdvanced()
.setHidden()
.setFunctionName('turnAroundZ');
@@ -1513,12 +1493,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D cube'), 'Cube3DObject', false)
.useStandardParameters(
'number',
gd.ParameterOptions.makeNewOptions().setDescription(
_('Angle (in degrees)')
)
)
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setRotationX')
.setHidden()
.setGetter('getRotationX');
@@ -1535,12 +1510,7 @@ module.exports = {
'res/conditions/3d_box.svg'
)
.addParameter('object', _('3D cube'), 'Cube3DObject', false)
.useStandardParameters(
'number',
gd.ParameterOptions.makeNewOptions().setDescription(
_('Angle (in degrees)')
)
)
.useStandardParameters('number', gd.ParameterOptions.makeNewOptions())
.setFunctionName('setRotationY')
.setHidden()
.setGetter('getRotationY');
@@ -1908,9 +1878,7 @@ module.exports = {
.addEffect('AmbientLight')
.setFullName(_('Ambient light'))
.setDescription(
_(
'A light that illuminates all objects from every direction. Often used along with a Directional light (though a Hemisphere light can be used instead of an Ambient light).'
)
_('A light that illuminates all objects from every direction.')
)
.markAsNotWorkingForObjects()
.markAsOnlyWorkingFor3D()
@@ -1931,11 +1899,7 @@ module.exports = {
const effect = extension
.addEffect('DirectionalLight')
.setFullName(_('Directional light'))
.setDescription(
_(
"A very far light source like the sun. This is the light to use for casting shadows for 3D objects (other lights won't emit shadows). Often used along with a Hemisphere light."
)
)
.setDescription(_('A very far light source like the sun.'))
.markAsNotWorkingForObjects()
.markAsOnlyWorkingFor3D()
.addIncludeFile('Extensions/3D/DirectionalLight.js');
@@ -2019,7 +1983,7 @@ module.exports = {
.setFullName(_('Hemisphere light'))
.setDescription(
_(
'A light that illuminates objects from every direction with a gradient. Often used along with a Directional light.'
'A light that illuminates objects from every direction with a gradient.'
)
)
.markAsNotWorkingForObjects()

View File

@@ -94,6 +94,16 @@ module.exports = {
.setLabel(_('Vertical alignment'))
.setGroup(_('Appearance'));
if (!objectContent.lineHeight) {
objectContent.lineHeight = 0;
}
objectProperties
.getOrCreate('lineHeight')
.setValue(objectContent.lineHeight.toString())
.setType('number')
.setLabel(_('Line height (0 = default)'))
.setGroup(_('Font'));
objectProperties
.getOrCreate('fontFamily')
.setValue(objectContent.fontFamily)
@@ -394,6 +404,19 @@ module.exports = {
expressionLabel: _('Get the wrapping width'),
expressionDescription: _('Get the wrapping width'),
},
{
functionName: 'LineHeight',
iconPath: 'res/actions/characterSize24.png',
type: 'number',
instructionLabel: _('Line height'),
paramLabel: _('Line height (0 = default)'),
conditionDescription: _('Compare the base line height of the text.'),
conditionSentence: _('the base line height'),
actionDescription: _('Set base line height'),
actionSentence: _('the base line height'),
expressionLabel: _('Get the base line height'),
expressionDescription: _('Get the base line height'),
},
];
addSettersAndGettersToObject(object, setterAndGetterProperties, 'BBText');

View File

@@ -32,6 +32,7 @@ namespace gdjs {
wordWrap: runtimeObject._wrapping,
wordWrapWidth: runtimeObject._wrappingWidth,
align: runtimeObject._textAlign as PIXI.TextStyleAlign | undefined,
lineHeight: runtimeObject._lineHeight > 0 ? runtimeObject._lineHeight : undefined,
},
});
instanceContainer
@@ -102,6 +103,13 @@ namespace gdjs {
this._pixiObject.dirty = true;
}
updateLineHeight(): void {
//@ts-ignore Private member usage.
this._pixiObject.textStyles.default.lineHeight =
this._object._lineHeight;
this._pixiObject.dirty = true;
}
updatePosition(): void {
if (this._object.isWrapping() && this._pixiObject.width !== 0) {
const alignmentX =

View File

@@ -20,6 +20,8 @@ namespace gdjs {
/** Alignment of the text: "left", "center" or "right" */
align: 'left' | 'center' | 'right';
verticalTextAlignment: 'top' | 'center' | 'bottom';
/** Line height for multiline text (0 = default) */
lineHeight: number;
};
};
export type BBTextObjectData = ObjectData & BBTextObjectDataType;
@@ -35,6 +37,7 @@ namespace gdjs {
align: string;
vta: string;
hidden: boolean;
lh: number;
};
export type BBTextObjectNetworkSyncData = ObjectNetworkSyncData &
@@ -61,6 +64,7 @@ namespace gdjs {
_textAlign: string;
_verticalTextAlignment: string;
_lineHeight: float;
_renderer: gdjs.BBTextRuntimeObjectRenderer;
@@ -87,6 +91,7 @@ namespace gdjs {
this._textAlign = objectData.content.align;
this._verticalTextAlignment =
objectData.content.verticalTextAlignment || 'top';
this._lineHeight = objectData.content.lineHeight || 0;
this.hidden = !objectData.content.visible;
this._renderer = new gdjs.BBTextRuntimeObjectRenderer(
@@ -142,6 +147,11 @@ namespace gdjs {
newObjectData.content.verticalTextAlignment
);
}
if (
oldObjectData.content.lineHeight !== newObjectData.content.lineHeight
) {
this.setLineHeight(newObjectData.content.lineHeight);
}
return true;
}
@@ -158,6 +168,7 @@ namespace gdjs {
align: this._textAlign,
vta: this._verticalTextAlignment,
hidden: this.hidden,
lh: this._lineHeight,
};
}
@@ -196,6 +207,9 @@ namespace gdjs {
if (this.hidden !== undefined) {
this.hide(networkSyncData.hidden);
}
if (networkSyncData.lh !== undefined) {
this.setLineHeight(networkSyncData.lh);
}
}
override extraInitializationFromInitialInstance(
@@ -397,6 +411,23 @@ namespace gdjs {
: 0)
);
}
/**
* Get line height of the BBText object.
* @return line height (0 = default)
*/
getLineHeight(): number {
return this._lineHeight;
}
/**
* Set line height of the BBText object.
* @param value line height (0 = default)
*/
setLineHeight(value: float): void {
this._lineHeight = value;
this._renderer.updateLineHeight();
}
}
// @ts-ignore
gdjs.registerObject('BBText::BBText', gdjs.BBTextRuntimeObject);

View File

@@ -389,15 +389,26 @@ namespace gdjs {
this.updatePosition();
}
setColor(rgbOrHexColor: string): void {
const tint = gdjs.rgbOrHexStringToNumber(rgbOrHexColor);
this._centerSprite.tint = tint;
setColor(rgbColor): void {
const colors = rgbColor.split(';');
if (colors.length < 3) {
return;
}
this._centerSprite.tint = gdjs.rgbToHexNumber(
parseInt(colors[0], 10),
parseInt(colors[1], 10),
parseInt(colors[2], 10)
);
for (
let borderCounter = 0;
borderCounter < this._borderSprites.length;
borderCounter++
) {
this._borderSprites[borderCounter].tint = tint;
this._borderSprites[borderCounter].tint = gdjs.rgbToHexNumber(
parseInt(colors[0], 10),
parseInt(colors[1], 10),
parseInt(colors[2], 10)
);
}
this._spritesContainer.cacheAsBitmap = false;
}
@@ -405,11 +416,11 @@ namespace gdjs {
getColor() {
const rgb = new PIXI.Color(this._centerSprite.tint).toRgbArray();
return (
Math.round(rgb[0] * 255) +
Math.floor(rgb[0] * 255) +
';' +
Math.round(rgb[1] * 255) +
Math.floor(rgb[1] * 255) +
';' +
Math.round(rgb[2] * 255)
Math.floor(rgb[2] * 255)
);
}

View File

@@ -21,11 +21,7 @@ module.exports = {
.setExtensionInformation(
'Physics2',
_('2D Physics Engine'),
"The 2D physics engine simulates realistic object physics, with gravity, forces, collisions, joints, etc. It's perfect for 2D games that need to have realistic behaving objects and a gameplay centered around it.\n" +
'\n' +
'Objects like floors or wall objects should usually be set to "Static" as type. Objects that should be moveable are usually "Dynamic" (default). "Kinematic" objects (typically, players or controlled characters) are only moved by their "linear velocity" and "angular velocity" - they can interact with other objects but only these other objects will move.\n' +
'\n' +
'Forces (and impulses) are expressed in all conditions/expressions/actions of the 2D physics engine in Newtons (N). Typical values for a force are 10-200 N. One meter is 100 pixels by default in the game (check the world scale). Mass is expressed in kilograms (kg).',
"The 2D physics engine simulates realistic object physics, with gravity, forces, collisions, joints, etc. It's perfect for 2D games that need to have realistic behaving objects and a gameplay centered around it.",
'Florian Rival, Franco Maciel',
'MIT'
)
@@ -531,7 +527,6 @@ module.exports = {
physics2Behavior,
sharedData
)
.markAsIrrelevantForChildObjects()
.setIncludeFile('Extensions/Physics2Behavior/physics2runtimebehavior.js')
.addIncludeFile('Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.js')
.addRequiredFile('Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.wasm')

View File

@@ -21,11 +21,7 @@ module.exports = {
.setExtensionInformation(
'Physics3D',
_('3D physics engine'),
"The 3D physics engine simulates realistic object physics, with gravity, forces, collisions, joints, etc. It's perfect for almost all 3D games.\n" +
'\n' +
'Objects like floors or wall objects should usually be set to "Static" as type. Objects that should be moveable are usually "Dynamic" (default). "Kinematic" objects (typically, players or controlled characters) are only moved by their "linear velocity" and "angular velocity" - they can interact with other objects but only these other objects will move.\n' +
'\n' +
'Forces (and impulses) are expressed in all conditions/expressions/actions of the 3D physics engine in Newtons (N). Typical values for a force are 10-200 N. One meter is 100 pixels by default in the game (check the world scale). Mass is expressed in kilograms (kg).',
"The 3D physics engine simulates realistic object physics, with gravity, forces, collisions, joints, etc. It's perfect for almost all 3D games.",
'Florian Rival',
'MIT'
)
@@ -679,7 +675,6 @@ module.exports = {
behavior,
sharedData
)
.markAsIrrelevantForChildObjects()
.addIncludeFile(
'Extensions/Physics3DBehavior/Physics3DRuntimeBehavior.js'
)
@@ -2048,12 +2043,7 @@ module.exports = {
'PhysicsCharacter3D',
_('3D physics character'),
'PhysicsCharacter3D',
_(
'Allow an object to jump and run on platforms that have the 3D physics behavior' +
'(and which are generally set to "Static" as type, unless the platform is animated/moved in events).\n' +
'\n' +
'This behavior is usually used with one or more "mapper" behavior to let the player move it.'
),
_('Jump and run on platforms.'),
'',
'JsPlatform/Extensions/physics_character3d.svg',
'PhysicsCharacter3D',
@@ -2622,7 +2612,7 @@ module.exports = {
'JumpSustainTime',
_('Jump sustain time'),
_(
'the jump sustain time of an object. This is the time during which keeping the jump button held allow the initial jump speed to be maintained'
'the jump sustain time of an object. This is the time during which keeping the jump button held allow the initial jump speed to be maintained.'
),
_('the jump sustain time'),
_('Character configuration'),
@@ -3310,11 +3300,7 @@ module.exports = {
'PhysicsCar3D',
_('3D physics car'),
'PhysicsCar3D',
_(
"Simulate a realistic car using the 3D physics engine. This is mostly useful for the car controlled by the player (it's usually too complex for other cars in a game).\n" +
'\n' +
'This behavior is usually used with one or more "mapper" behavior to let the player move it.'
),
_('Simulate a realistic car using the 3D physics engine.'),
'',
'JsPlatform/Extensions/physics_car3d.svg',
'PhysicsCar3D',

View File

@@ -37,8 +37,7 @@ void DeclarePhysicsBehaviorExtension(gd::PlatformExtension& extension) {
"res/physics-deprecated32.png",
"PhysicsBehavior",
std::make_shared<PhysicsBehavior>(),
std::make_shared<ScenePhysicsDatas>())
.MarkAsIrrelevantForChildObjects();
std::make_shared<ScenePhysicsDatas>());
aut.AddAction("SetStatic",
("Make the object static"),

View File

@@ -202,18 +202,28 @@ namespace gdjs {
this._loadingSpineAtlases.clear();
}
unloadResource(resourceData: ResourceData): void {
const loadedSpineAtlas = this._loadedSpineAtlases.get(resourceData);
if (loadedSpineAtlas) {
loadedSpineAtlas.dispose();
this._loadedSpineAtlases.delete(resourceData);
}
/**
* Unload the specified list of resources:
* this clears the Spine atlases loaded in this manager.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedSpineAtlas = this._loadedSpineAtlases.get(resourceData);
if (loadedSpineAtlas) {
loadedSpineAtlas.dispose();
this._loadedSpineAtlases.delete(resourceData);
}
const loadingSpineAtlas = this._loadingSpineAtlases.get(resourceData);
if (loadingSpineAtlas) {
loadingSpineAtlas.then((atl) => atl.dispose());
this._loadingSpineAtlases.delete(resourceData);
}
const loadingSpineAtlas = this._loadingSpineAtlases.get(resourceData);
if (loadingSpineAtlas) {
loadingSpineAtlas.then((atl) => atl.dispose());
this._loadingSpineAtlases.delete(resourceData);
}
});
}
}
}

View File

@@ -127,11 +127,21 @@ namespace gdjs {
this._loadedSpines.clear();
}
unloadResource(resourceData: ResourceData): void {
const loadedSpine = this._loadedSpines.get(resourceData);
if (loadedSpine) {
this._loadedSpines.delete(resourceData);
}
/**
* Unload the specified list of resources:
* this clears the Spine skeleton data loaded in this manager.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedSpine = this._loadedSpines.get(resourceData);
if (loadedSpine) {
this._loadedSpines.delete(resourceData);
}
});
}
}
}

View File

@@ -454,8 +454,8 @@ void DeclareTextObjectExtension(gd::PlatformExtension& extension) {
_("Line height"),
_("the line height of a text object"),
_("the line height"),
"",
"res/actions/font24.png")
_("Font"),
"res/conditions/characterSize24.png")
.AddParameter("object", _("Object"), "Text")
.UseStandardParameters("number", gd::ParameterOptions::MakeNewOptions());

View File

@@ -38,6 +38,14 @@ class TextObjectJsExtension : public gd::PlatformExtension {
GetAllExpressionsForObject("TextObject::Text")["FontSize"]
.SetFunctionName("getCharacterSize");
GetAllActionsForObject("TextObject::Text")["TextObject::Text::SetLineHeight"]
.SetFunctionName("setLineHeight")
.SetGetter("getLineHeight");
GetAllConditionsForObject("TextObject::Text")["TextObject::Text::LineHeight"]
.SetFunctionName("getLineHeight");
GetAllExpressionsForObject("TextObject::Text")["LineHeight"]
.SetFunctionName("getLineHeight");
GetAllActionsForObject("TextObject::Text")["TextObject::Font"]
.SetFunctionName("setFontName");
@@ -96,14 +104,6 @@ class TextObjectJsExtension : public gd::PlatformExtension {
.SetFunctionName("setOutlineThickness")
.SetGetter("getOutlineThickness");
GetAllExpressionsForObject("TextObject::Text")["LineHeight"]
.SetFunctionName("getLineHeight");
GetAllConditionsForObject("TextObject::Text")["TextObject::Text::LineHeight"]
.SetFunctionName("getLineHeight");
GetAllActionsForObject("TextObject::Text")["TextObject::Text::SetLineHeight"]
.SetFunctionName("setLineHeight")
.SetGetter("getLineHeight");
GetAllActionsForObject("TextObject::Text")["TextObject::ShowShadow"]
.SetFunctionName("showShadow");
GetAllConditionsForObject("TextObject::Text")["TextObject::Text::IsShadowEnabled"]

View File

@@ -20,7 +20,6 @@ using namespace std;
TextObject::TextObject()
: text("Text"),
characterSize(20),
lineHeight(0),
fontName(""),
smoothed(true),
bold(false),
@@ -37,7 +36,8 @@ TextObject::TextObject()
shadowOpacity(127),
shadowAngle(90),
shadowDistance(4),
shadowBlurRadius(2) {}
shadowBlurRadius(2),
lineHeight(0) {}
TextObject::~TextObject() {};
@@ -51,10 +51,6 @@ bool TextObject::UpdateProperty(const gd::String& propertyName,
characterSize = newValue.To<double>();
return true;
}
if (propertyName == "lineHeight") {
lineHeight = newValue.To<double>();
return true;
}
if (propertyName == "font") {
fontName = newValue;
return true;
@@ -115,6 +111,10 @@ bool TextObject::UpdateProperty(const gd::String& propertyName,
shadowBlurRadius = newValue.To<double>();
return true;
}
if (propertyName == "lineHeight") {
lineHeight = newValue.To<double>();
return true;
}
return false;
}
@@ -134,13 +134,6 @@ std::map<gd::String, gd::PropertyDescriptor> TextObject::GetProperties() const {
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetGroup(_("Font"));
objectProperties["lineHeight"]
.SetValue(gd::String::From(lineHeight))
.SetType("number")
.SetLabel(_("Line height"))
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetGroup(_("Font"));
objectProperties["font"]
.SetValue(fontName)
.SetType("resource")
@@ -267,6 +260,13 @@ std::map<gd::String, gd::PropertyDescriptor> TextObject::GetProperties() const {
.SetAdvanced()
.SetQuickCustomizationVisibility(gd::QuickCustomization::Hidden);
objectProperties["lineHeight"]
.SetValue(gd::String::From(lineHeight))
.SetType("number")
.SetLabel(_("Line height"))
.SetMeasurementUnit(gd::MeasurementUnit::GetPixel())
.SetGroup(_("Font"));
return objectProperties;
}
@@ -283,7 +283,6 @@ void TextObject::DoUnserializeFrom(gd::Project& project,
SetCharacterSize(content.GetChild("characterSize", 0, "CharacterSize")
.GetValue()
.GetInt());
SetLineHeight(content.GetDoubleAttribute("lineHeight", 0));
smoothed = content.GetBoolAttribute("smoothed");
bold = content.GetBoolAttribute("bold");
italic = content.GetBoolAttribute("italic");
@@ -317,6 +316,8 @@ void TextObject::DoUnserializeFrom(gd::Project& project,
SetShadowAngle(content.GetIntAttribute("shadowAngle", 90));
SetShadowDistance(content.GetIntAttribute("shadowDistance", 4));
SetShadowBlurRadius(content.GetIntAttribute("shadowBlurRadius", 2));
SetLineHeight(content.GetDoubleAttribute("lineHeight", 0));
}
}
@@ -352,7 +353,6 @@ void TextObject::DoSerializeTo(gd::SerializerElement& element) const {
content.AddChild("textAlignment").SetValue(GetTextAlignment());
content.AddChild("verticalTextAlignment").SetValue(GetVerticalTextAlignment());
content.AddChild("characterSize").SetValue(GetCharacterSize());
content.AddChild("lineHeight").SetValue(GetLineHeight());
content.AddChild("color").SetValue(GetColor());
content.SetAttribute("smoothed", smoothed);
@@ -370,6 +370,7 @@ void TextObject::DoSerializeTo(gd::SerializerElement& element) const {
content.SetAttribute("shadowAngle", shadowAngle);
content.SetAttribute("shadowDistance", shadowDistance);
content.SetAttribute("shadowBlurRadius", shadowBlurRadius);
content.SetAttribute("lineHeight", lineHeight);
}
void TextObject::ExposeResources(gd::ArbitraryResourceWorker& worker) {

View File

@@ -49,12 +49,6 @@ class GD_EXTENSION_API TextObject : public gd::ObjectConfiguration {
*/
inline double GetCharacterSize() const { return characterSize; };
/** \brief Change the line height. */
inline void SetLineHeight(double value) { lineHeight = value; };
/** \brief Get the line height. */
inline double GetLineHeight() const { return lineHeight; };
/** \brief Return the name of the font resource used for the text.
*/
inline const gd::String& GetFontName() const { return fontName; };
@@ -119,6 +113,9 @@ class GD_EXTENSION_API TextObject : public gd::ObjectConfiguration {
void SetShadowBlurRadius(double value) { shadowBlurRadius = value; };
double GetShadowBlurRadius() const { return shadowBlurRadius; };
void SetLineHeight(double value) { lineHeight = value; };
double GetLineHeight() const { return lineHeight; };
private:
virtual void DoUnserializeFrom(gd::Project& project,
const gd::SerializerElement& element) override;
@@ -126,7 +123,6 @@ class GD_EXTENSION_API TextObject : public gd::ObjectConfiguration {
gd::String text;
double characterSize;
double lineHeight;
gd::String fontName;
bool smoothed;
bool bold, italic, underlined;
@@ -144,4 +140,5 @@ class GD_EXTENSION_API TextObject : public gd::ObjectConfiguration {
double shadowAngle;
double shadowDistance;
double shadowBlurRadius;
double lineHeight;
};

View File

@@ -64,6 +64,7 @@ namespace gdjs {
style.wordWrap = this._object._wrapping;
style.wordWrapWidth = this._object._wrappingWidth;
style.breakWords = true;
style.lineHeight = this._object._lineHeight > 0 ? this._object._lineHeight : undefined;
style.stroke = gdjs.rgbToHexNumber(
this._object._outlineColor[0],
this._object._outlineColor[1],
@@ -86,7 +87,6 @@ namespace gdjs {
? style.dropShadowDistance + style.dropShadowBlur
: 0;
style.padding = Math.ceil(this._object._padding + extraPaddingForShadow);
style.lineHeight = this._object._lineHeight;
// Prevent spikey outlines by adding a miter limit
style.miterLimit = 3;

View File

@@ -22,8 +22,7 @@ namespace gdjs {
text: string;
textAlignment: string;
verticalTextAlignment: string;
/** The line height */
lineHeight: float;
lineHeight: number;
isOutlineEnabled: boolean;
outlineThickness: float;
@@ -36,6 +35,7 @@ namespace gdjs {
shadowDistance: float;
shadowAngle: float;
shadowBlurRadius: float;
lineHeight: float;
};
};
@@ -92,6 +92,7 @@ namespace gdjs {
_wrapping: boolean = false;
// A wrapping of 1 makes games crash on Firefox
_wrappingWidth: float = 100;
_lineHeight: float;
_isOutlineEnabled: boolean;
_outlineThickness: float;
@@ -104,9 +105,8 @@ namespace gdjs {
_shadowAngle: float;
_shadowBlur: float;
_lineHeight: float;
_padding: integer = 5;
_lineHeight: float = 0;
_str: string;
_renderer: gdjs.TextRuntimeObjectRenderer;
@@ -133,6 +133,7 @@ namespace gdjs {
this._str = content.text;
this._textAlign = content.textAlignment || 'left';
this._verticalTextAlignment = content.verticalTextAlignment || 'top';
this._lineHeight = content.lineHeight || 0;
this._isOutlineEnabled = content.isOutlineEnabled;
this._outlineThickness = content.outlineThickness;
@@ -144,7 +145,6 @@ namespace gdjs {
this._shadowDistance = content.shadowDistance;
this._shadowBlur = content.shadowBlurRadius;
this._shadowAngle = content.shadowAngle;
this._lineHeight = content.lineHeight || 0;
this._renderer = new gdjs.TextRuntimeObjectRenderer(
this,
@@ -217,8 +217,8 @@ namespace gdjs {
if (oldContent.shadowBlurRadius !== newContent.shadowBlurRadius) {
this.setShadowBlurRadius(newContent.shadowBlurRadius);
}
if ((oldContent.lineHeight || 0) !== (newContent.lineHeight || 0)) {
this.setLineHeight(newContent.lineHeight || 0);
if (oldContent.lineHeight !== newContent.lineHeight) {
this.setLineHeight(newContent.lineHeight);
}
return true;
}
@@ -247,8 +247,8 @@ namespace gdjs {
shd: this._shadowDistance,
sha: this._shadowAngle,
shb: this._shadowBlur,
lh: this._lineHeight,
pad: this._padding,
lh: this._lineHeight,
};
}
@@ -322,12 +322,12 @@ namespace gdjs {
if (networkSyncData.shb !== undefined) {
this.setShadowBlurRadius(networkSyncData.shb);
}
if (networkSyncData.lh !== undefined) {
this.setLineHeight(networkSyncData.lh);
}
if (networkSyncData.pad !== undefined) {
this.setPadding(networkSyncData.pad);
}
if (networkSyncData.lh !== undefined) {
this.setLineHeight(networkSyncData.lh);
}
}
override getRendererObject() {
@@ -457,22 +457,6 @@ namespace gdjs {
this._renderer.updateStyle();
}
/**
* Get the line height of the text.
*/
getLineHeight(): float {
return this._lineHeight;
}
/**
* Set the line height of the text.
* @param value The new line height for the text.
*/
setLineHeight(value: float): void {
this._lineHeight = value;
this._renderer.updateStyle();
}
/**
* Set the name of the resource to use for the font.
* @param fontResourceName The name of the font resource.
@@ -602,10 +586,16 @@ namespace gdjs {
/**
* Change the text color.
* @param rgbOrHexColor color as a "R;G;B" string, for example: "255;0;0"
* @param colorString color as a "R;G;B" string, for example: "255;0;0"
*/
setColor(rgbOrHexColor: string): void {
this._color = gdjs.rgbOrHexToRGBColor(rgbOrHexColor);
setColor(colorString: string): void {
const color = colorString.split(';');
if (color.length < 3) {
return;
}
this._color[0] = parseInt(color[0], 10);
this._color[1] = parseInt(color[1], 10);
this._color[2] = parseInt(color[2], 10);
this._useGradient = false;
this._renderer.updateStyle();
}
@@ -714,12 +704,18 @@ namespace gdjs {
/**
* Set the outline for the text object.
* @param rgbOrHexColor color as a "R;G;B" string, for example: "255;0;0"
* @param str color as a "R;G;B" string, for example: "255;0;0"
* @param thickness thickness of the outline (0 = disabled)
* @deprecated Prefer independent setters.
*/
setOutline(rgbOrHexColor: string, thickness: number): void {
this._outlineColor = gdjs.rgbOrHexToRGBColor(rgbOrHexColor);
setOutline(str: string, thickness: number): void {
const color = str.split(';');
if (color.length < 3) {
return;
}
this._outlineColor[0] = parseInt(color[0], 10);
this._outlineColor[1] = parseInt(color[1], 10);
this._outlineColor[2] = parseInt(color[2], 10);
this._outlineThickness = thickness;
this._renderer.updateStyle();
}
@@ -761,19 +757,25 @@ namespace gdjs {
/**
* Set the shadow for the text object.
* @param rgbOrHexColor color as a "R;G;B" string, for example: "255;0;0"
* @param str color as a "R;G;B" string, for example: "255;0;0"
* @param distance distance between the shadow and the text, in pixels.
* @param blur amount of shadow blur, in pixels.
* @param angle shadow offset direction, in degrees.
* @deprecated Prefer independent setters.
*/
setShadow(
rgbOrHexColor: string,
str: string,
distance: number,
blur: integer,
angle: float
): void {
this._shadowColor = gdjs.rgbOrHexToRGBColor(rgbOrHexColor);
const color = str.split(';');
if (color.length < 3) {
return;
}
this._shadowColor[0] = parseInt(color[0], 10);
this._shadowColor[1] = parseInt(color[1], 10);
this._shadowColor[2] = parseInt(color[2], 10);
this._shadowDistance = distance;
this._shadowBlur = blur;
this._shadowAngle = angle;
@@ -886,18 +888,38 @@ namespace gdjs {
strThirdColor: string,
strFourthColor: string
): void {
const colorFirst = strFirstColor.split(';');
const colorSecond = strSecondColor.split(';');
const colorThird = strThirdColor.split(';');
const colorFourth = strFourthColor.split(';');
this._gradient = [];
if (strFirstColor) {
this._gradient.push(gdjs.rgbOrHexToRGBColor(strFirstColor));
if (colorFirst.length == 3) {
this._gradient.push([
parseInt(colorFirst[0], 10),
parseInt(colorFirst[1], 10),
parseInt(colorFirst[2], 10),
]);
}
if (strSecondColor) {
this._gradient.push(gdjs.rgbOrHexToRGBColor(strSecondColor));
if (colorSecond.length == 3) {
this._gradient.push([
parseInt(colorSecond[0], 10),
parseInt(colorSecond[1], 10),
parseInt(colorSecond[2], 10),
]);
}
if (strThirdColor) {
this._gradient.push(gdjs.rgbOrHexToRGBColor(strThirdColor));
if (colorThird.length == 3) {
this._gradient.push([
parseInt(colorThird[0], 10),
parseInt(colorThird[1], 10),
parseInt(colorThird[2], 10),
]);
}
if (strFourthColor) {
this._gradient.push(gdjs.rgbOrHexToRGBColor(strFourthColor));
if (colorFourth.length == 3) {
this._gradient.push([
parseInt(colorFourth[0], 10),
parseInt(colorFourth[1], 10),
parseInt(colorFourth[2], 10),
]);
}
this._gradientType = strGradientType;
this._useGradient = this._gradient.length > 1 ? true : false;
@@ -920,6 +942,23 @@ namespace gdjs {
this._padding = value;
this._renderer.updateStyle();
}
/**
* Get the line height of the text object.
* @return the line height
*/
getLineHeight(): number {
return this._lineHeight;
}
/**
* Set the line height of the text object.
* @param value the line height
*/
setLineHeight(value: float): void {
this._lineHeight = value;
this._renderer.updateStyle();
}
}
gdjs.registerObject('TextObject::Text', gdjs.TextRuntimeObject);
}

View File

@@ -92,18 +92,28 @@ namespace gdjs {
-this._object._yOffset % this._tiledSprite.texture.height;
}
setColor(rgbOrHexColor: string): void {
this._tiledSprite.tint = gdjs.rgbOrHexStringToNumber(rgbOrHexColor);
setColor(rgbColor: string): void {
const colors = rgbColor.split(';');
if (colors.length < 3) {
return;
}
this._tiledSprite.tint =
'0x' +
gdjs.rgbToHex(
parseInt(colors[0], 10),
parseInt(colors[1], 10),
parseInt(colors[2], 10)
);
}
getColor() {
const rgb = new PIXI.Color(this._tiledSprite.tint).toRgbArray();
return (
Math.round(rgb[0] * 255) +
Math.floor(rgb[0] * 255) +
';' +
Math.round(rgb[1] * 255) +
Math.floor(rgb[1] * 255) +
';' +
Math.round(rgb[2] * 255)
Math.floor(rgb[2] * 255)
);
}

View File

@@ -347,21 +347,17 @@ describe('gdjs.TweenRuntimeBehavior', () => {
// Check that there is no NaN.
for (let i = 0; i < 11; i++) {
runtimeScene.renderAndStep(1000 / 60);
// The tween tries to set the camera zoom to 0, but it has no effect
// because it doesn't make sense.
expect(camera.getCameraZoom(runtimeScene, '', 0)).to.be(1);
expect(camera.getCameraZoom(runtimeScene, '', 0)).to.be(0);
}
});
it('can tween a layer camera zoom from 0', () => {
// The zoom stays at 1 because 0 doesn't make sense.
camera.setCameraZoom(runtimeScene, 0, '', 0);
// Here, it actually tweens from 1 to 1.
tween.tweenCameraZoom2(runtimeScene, 'MyTween', 1, '', 'linear', 0.25);
// A camera zoom of 0 doesn't make sense.
// Check that there is no NaN.
for (let i = 0; i < 11; i++) {
expect(camera.getCameraZoom(runtimeScene, '', 0)).to.be(1);
expect(camera.getCameraZoom(runtimeScene, '', 0)).to.be(0);
runtimeScene.renderAndStep(1000 / 60);
}
expect(camera.getCameraZoom(runtimeScene, '', 0)).to.be(1);

View File

@@ -145,7 +145,6 @@ describe('gdjs.TweenRuntimeBehavior', () => {
shadowDistance: 4,
shadowAngle: 90,
shadowBlurRadius: 2,
lineHeight: 0,
},
});
runtimeScene.addObject(object);

View File

@@ -145,7 +145,6 @@ describe('gdjs.TweenRuntimeBehavior', () => {
shadowDistance: 4,
shadowAngle: 90,
shadowBlurRadius: 2,
lineHeight: 0,
},
});
runtimeScene.addObject(object);

View File

@@ -1326,11 +1326,11 @@ namespace gdjs {
lightness
);
owner.setColor(
Math.round(rgbFromHslColor[0]) +
Math.floor(rgbFromHslColor[0]) +
';' +
Math.round(rgbFromHslColor[1]) +
Math.floor(rgbFromHslColor[1]) +
';' +
Math.round(rgbFromHslColor[2])
Math.floor(rgbFromHslColor[2])
);
};
} else {
@@ -1439,11 +1439,12 @@ namespace gdjs {
if (!isColorable(this.owner)) return;
const owner = this.owner;
const rgbFromColor = gdjs.rgbOrHexToRGBColor(owner.getColor());
const rgbFromColor: string[] = owner.getColor().split(';');
if (rgbFromColor.length < 3) return;
const hslFromColor = gdjs.evtTools.tween.rgbToHsl(
rgbFromColor[0],
rgbFromColor[1],
rgbFromColor[2]
parseFloat(rgbFromColor[0]),
parseFloat(rgbFromColor[1]),
parseFloat(rgbFromColor[2])
);
const toH = animateHue ? toHue : hslFromColor[0];
@@ -1473,11 +1474,11 @@ namespace gdjs {
);
owner.setColor(
Math.round(rgbFromHslColor[0]) +
Math.floor(rgbFromHslColor[0]) +
';' +
Math.round(rgbFromHslColor[1]) +
Math.floor(rgbFromHslColor[1]) +
';' +
Math.round(rgbFromHslColor[2])
Math.floor(rgbFromHslColor[2])
);
},

View File

@@ -841,23 +841,13 @@ CommonInstructionsExtension::CommonInstructionsExtension() {
event.GetParameterObjects(),
parentContext.GetCurrentObject());
if (realObjects.size() == 1) {
parentContext.ObjectsListNeeded(realObjects[0]);
callingCode += "var objects = [];\n";
for (unsigned int i = 0; i < realObjects.size(); ++i) {
parentContext.ObjectsListNeeded(realObjects[i]);
callingCode +=
"const objects = " +
codeGenerator.GetObjectListName(realObjects[0], parentContext) +
";\n";
} else {
// Groups are rarely used in JS events so it's fine to make
// allocations.
callingCode += "const objects = [];\n";
for (unsigned int i = 0; i < realObjects.size(); ++i) {
parentContext.ObjectsListNeeded(realObjects[i]);
callingCode += "objects.push.apply(objects," +
codeGenerator.GetObjectListName(realObjects[i],
parentContext) +
");\n";
}
"objects.push.apply(objects," +
codeGenerator.GetObjectListName(realObjects[i], parentContext) +
");\n";
}
}

View File

@@ -18,8 +18,6 @@ KeyboardExtension::KeyboardExtension() {
"gdjs.evtTools.input.wasKeyReleased");
GetAllConditions()["KeyFromTextPressed"].SetFunctionName(
"gdjs.evtTools.input.isKeyPressed");
GetAllConditions()["KeyFromTextJustPressed"].SetFunctionName(
"gdjs.evtTools.input.wasKeyJustPressed");
GetAllConditions()["KeyFromTextReleased"].SetFunctionName(
"gdjs.evtTools.input.wasKeyReleased");
GetAllConditions()["AnyKeyPressed"].SetFunctionName(

View File

@@ -26,28 +26,6 @@ The game engine is in the _Runtime_ folder. If you want to work on the engine di
- To launch type checking with TypeScript, run `npm install` and `npm run check-types` in `GDJS` folder.
#### Building GDJS Runtime
To build the GDJS Runtime, run `npm run build` in the `GDJS` folder.
**Build Options:**
- **Production build (default)**: `npm run build` - builds with minification enabled
- **Debug build**: `npm run build -- --debug` - builds without minification for easier debugging
- **Custom output path**: `npm run build -- --out=/path/to/output` - specify custom output directory
**Examples:**
```bash
# Standard production build
npm run build
# Debug build for development (no minification)
npm run build -- --debug
# Debug build with custom output path
npm run build -- --debug --out=./debug-build
```
### GDJS Platform (exporters, code generation...)
Check the [GDJS Platform](https://docs.gdevelop.io/GDJS%20Documentation/index.html) documentation or the [full GDevelop developers documentation](https://docs.gdevelop.io/).

View File

@@ -163,18 +163,28 @@ namespace gdjs {
}
}
unloadResource(resourceData: ResourceData): void {
const loadedThreeModel = this._loadedThreeModels.get(resourceData);
if (loadedThreeModel) {
loadedThreeModel.scene.clear();
this._loadedThreeModels.delete(resourceData);
}
/**
* Unload the specified list of resources:
* this clears the models, resources loaded and destroy 3D models loaders in this manager.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedThreeModel = this._loadedThreeModels.get(resourceData);
if (loadedThreeModel) {
loadedThreeModel.scene.clear();
this._loadedThreeModels.delete(resourceData);
}
const downloadedArrayBuffer =
this._downloadedArrayBuffers.get(resourceData);
if (downloadedArrayBuffer) {
this._downloadedArrayBuffers.delete(resourceData);
}
const downloadedArrayBuffer =
this._downloadedArrayBuffers.get(resourceData);
if (downloadedArrayBuffer) {
this._downloadedArrayBuffers.delete(resourceData);
}
});
}
}
}

View File

@@ -534,9 +534,7 @@ namespace gdjs {
`Unloading of resources of kind ${kindResourceManager} for scene ${unloadedSceneName}: `,
resources.map((resource) => resource.name).join(', ')
);
for (const resource of resources) {
resourceManager.unloadResource(resource);
}
resourceManager.unloadResourcesList(resources);
}
}

View File

@@ -31,19 +31,19 @@ namespace gdjs {
getResourceKinds(): Array<ResourceKind>;
/**
* Clear all resources, data, loaders stored by this manager.
* Should clear all resources, data, loaders stored by this manager.
* Using the manager after calling this method is undefined behavior.
*/
dispose(): void;
/**
* Clear any data in cache for a resource. Embedded resources are also
* cleared.
* Should clear all specified resources data and anything stored by this manager
* for these resources.
*
* Usually called when scene resources are unloaded.
* Usually called when scene resoures are unloaded.
*
* @param resourceData The resource to clear
* @param resourcesList The list of specific resources that need to be clear
*/
unloadResource(resourceData: ResourceData): void;
unloadResourcesList(resourcesList: ResourceData[]): void;
}
}

View File

@@ -462,12 +462,12 @@ namespace gdjs {
/**
* @param instanceContainer the container owning the layer
* @param layerName The lighting layer with the ambient color.
* @param rgbOrHexColor The color, in RGB format ("128;200;255").
* @param rgbColor The color, in RGB format ("128;200;255").
*/
export const setLayerAmbientLightColor = function (
instanceContainer: gdjs.RuntimeInstanceContainer,
layerName: string,
rgbOrHexColor: string
rgbColor: string
) {
if (
!instanceContainer.hasLayer(layerName) ||
@@ -475,10 +475,17 @@ namespace gdjs {
) {
return;
}
const color = gdjs.rgbOrHexToRGBColor(rgbOrHexColor);
const colors = rgbColor.split(';');
if (colors.length < 3) {
return;
}
return instanceContainer
.getLayer(layerName)
.setClearColor(color[0], color[1], color[2]);
.setClearColor(
parseInt(colors[0], 10),
parseInt(colors[1], 10),
parseInt(colors[2], 10)
);
};
}
}

View File

@@ -153,7 +153,7 @@ namespace gdjs {
};
/**
* Return true if the specified key is pressed (i.e: just pressed or held down).
* Return true if the specified key is pressed
*
*/
export const isKeyPressed = function (
@@ -170,22 +170,8 @@ namespace gdjs {
};
/**
* Return true if the specified key was just pressed (i.e: it started being pressed
* during this frame).
*/
export const wasKeyJustPressed = function (
instanceContainer: gdjs.RuntimeInstanceContainer,
key: string
) {
return instanceContainer
.getGame()
.getInputManager()
.wasKeyJustPressed(gdjs.evtTools.input.keysNameToCode[key]);
};
/**
* Return true if the specified key was just released (i.e: it stopped being pressed
* during this frame).
* Return true if the specified key was just released
*
*/
export const wasKeyReleased = function (
instanceContainer: gdjs.RuntimeInstanceContainer,
@@ -201,7 +187,7 @@ namespace gdjs {
};
/**
* Return the name of the last key pressed in the game.
* Return the name of the last key pressed in the game
*/
export const lastPressedKey = function (
instanceContainer: gdjs.RuntimeInstanceContainer

View File

@@ -24,12 +24,19 @@ namespace gdjs {
export const setBackgroundColor = function (
runtimeScene: gdjs.RuntimeScene,
rgbOrHexColor: string
rgbColor: string
) {
const color = gdjs.rgbOrHexToRGBColor(rgbOrHexColor);
const colors = rgbColor.split(';');
if (colors.length < 3) {
return;
}
runtimeScene
.getScene()
.setBackgroundColor(color[0], color[1], color[2]);
.setBackgroundColor(
parseInt(colors[0]),
parseInt(colors[1]),
parseInt(colors[2])
);
};
export const getElapsedTimeInSeconds = function (

View File

@@ -206,16 +206,26 @@ namespace gdjs {
this._loadedFontFamilySet.clear();
}
unloadResource(resourceData: ResourceData): void {
const resource = this._loadedFontFamily.get(resourceData);
if (resource) {
this._loadedFontFamily.delete(resourceData);
}
/**
* Unload the specified list of resources:
* this clears the caches of loaded font families.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const resource = this._loadedFontFamily.get(resourceData);
if (resource) {
this._loadedFontFamily.delete(resourceData);
}
const fontName = this._getFontFamilyFromFilename(resourceData);
if (fontName) {
this._loadedFontFamilySet.delete(fontName);
}
const fontName = this._getFontFamilyFromFilename(resourceData);
if (fontName) {
this._loadedFontFamilySet.delete(fontName);
}
});
}
}

View File

@@ -12,6 +12,7 @@ namespace gdjs {
const logger = new gdjs.Logger('Engine runtime');
const hexStringRegex = /^(#{0,1}[A-Fa-f0-9]{6})$/;
const shorthandHexStringRegex = /^(#{0,1}[A-Fa-f0-9]{3})$/;
const rgbStringRegex = /^(\d{1,3};\d{1,3};\d{1,3})/;
/**
* Contains functions used by events (this is a convention only, functions can actually
@@ -104,9 +105,9 @@ namespace gdjs {
export const rgbOrHexToRGBColor = function (
value: string
): [number, number, number] {
// TODO Add a `result` parameter to allow to reuse the returned array.
if (!value.startsWith('#')) {
const splitValue = value.split(';');
const rgbColor = extractRGBString(value);
if (rgbColor) {
const splitValue = rgbColor.split(';');
// If a RGB string is provided, return the RGB object.
if (splitValue.length === 3) {
return [
@@ -144,11 +145,11 @@ namespace gdjs {
* @param b Blue
*/
export const rgbToHexNumber = function (
r: float,
g: float,
b: float
r: integer,
g: integer,
b: integer
): integer {
return (Math.round(r) << 16) + (Math.round(g) << 8) + Math.round(b);
return (r << 16) + (g << 8) + b;
};
/**
@@ -191,6 +192,12 @@ namespace gdjs {
return matches[0];
};
export const extractRGBString = (str: string): string | null => {
const matches = str.match(rgbStringRegex);
if (!matches) return null;
return matches[0];
};
/**
* Get a random integer between 0 and max.
* @param max The maximum value (inclusive).

View File

@@ -365,7 +365,7 @@ namespace gdjs {
* It is basically a container to associate channels to sounds and keep a list
* of all sounds being played.
*/
export class HowlerSoundManager implements gdjs.ResourceManager {
export class HowlerSoundManager {
_loadedMusics = new gdjs.ResourceCache<Howl>();
_loadedSounds = new gdjs.ResourceCache<Howl>();
_availableResources: Record<string, ResourceData> = {};
@@ -940,16 +940,26 @@ namespace gdjs {
this.unloadAll();
}
unloadResource(resourceData: ResourceData): void {
const musicRes = this._loadedMusics.get(resourceData);
if (musicRes) {
this.unloadAudio(resourceData.name, true);
}
/**
* Unload the specified list of resources:
* this unloads all audio from the specified resources from memory.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const musicRes = this._loadedMusics.get(resourceData);
if (musicRes) {
this.unloadAudio(resourceData.name, true);
}
const soundRes = this._loadedSounds.get(resourceData);
if (soundRes) {
this.unloadAudio(resourceData.name, false);
}
const soundRes = this._loadedSounds.get(resourceData);
if (soundRes) {
this.unloadAudio(resourceData.name, false);
}
});
}
}

View File

@@ -23,13 +23,12 @@ namespace gdjs {
* variants and should default to their left variant values
* if location is not specified.
*/
private static _DEFAULT_LEFT_VARIANT_KEYS: integer[] = [16, 17, 18, 91];
private _pressedKeys: Hashtable<boolean>;
private _justPressedKeys: Hashtable<boolean>;
private _releasedKeys: Hashtable<boolean>;
private _lastPressedKey: float = 0;
private _pressedMouseButtons: Array<boolean>;
private _releasedMouseButtons: Array<boolean>;
static _DEFAULT_LEFT_VARIANT_KEYS: integer[] = [16, 17, 18, 91];
_pressedKeys: Hashtable<boolean>;
_releasedKeys: Hashtable<boolean>;
_lastPressedKey: float = 0;
_pressedMouseButtons: Array<boolean>;
_releasedMouseButtons: Array<boolean>;
/**
* The cursor X position (moved by mouse and touch events).
*/
@@ -80,7 +79,6 @@ namespace gdjs {
constructor() {
this._pressedKeys = new Hashtable();
this._justPressedKeys = new Hashtable();
this._releasedKeys = new Hashtable();
this._pressedMouseButtons = new Array(5);
this._releasedMouseButtons = new Array(5);
@@ -126,7 +124,6 @@ namespace gdjs {
location
);
this._pressedKeys.put(locationAwareKeyCode, true);
this._justPressedKeys.put(locationAwareKeyCode, true);
this._lastPressedKey = locationAwareKeyCode;
}
@@ -143,34 +140,9 @@ namespace gdjs {
location
);
this._pressedKeys.put(locationAwareKeyCode, false);
this._justPressedKeys.put(locationAwareKeyCode, false);
this._releasedKeys.put(locationAwareKeyCode, true);
}
/**
* Release all keys that are currently pressed.
* Note: if you want to discard pressed keys without considering them as
* released, check `clearAllPressedKeys` instead.
*/
releaseAllPressedKeys(): void {
for (const locationAwareKeyCode in this._pressedKeys.items) {
this._pressedKeys.put(locationAwareKeyCode, false);
this._justPressedKeys.put(locationAwareKeyCode, false);
this._releasedKeys.put(locationAwareKeyCode, true);
}
}
/**
* Clears all stored pressed keys without making the keys go through
* the release state.
* Note: prefer to use `releaseAllPressedKeys` instead, as it corresponds
* to a normal key release.
*/
clearAllPressedKeys(): void {
this._pressedKeys.clear();
this._justPressedKeys.clear();
}
/**
* Return the location-aware code of the last key that was pressed.
* @return The location-aware code of the last key pressed.
@@ -180,21 +152,14 @@ namespace gdjs {
}
/**
* Return true if the key corresponding to the location-aware keyCode is pressed
* (either it was just pressed or is still held down).
* Return true if the key corresponding to the location-aware keyCode is pressed.
* @param locationAwareKeyCode The location-aware key code to be tested.
*/
isKeyPressed(locationAwareKeyCode: number): boolean {
return !!this._pressedKeys.get(locationAwareKeyCode);
}
/**
* Return true if the key corresponding to the location-aware keyCode
* was just pressed during the last frame.
* @param locationAwareKeyCode The location-aware key code to be tested.
*/
wasKeyJustPressed(locationAwareKeyCode: number): boolean {
return !!this._justPressedKeys.get(locationAwareKeyCode);
return (
this._pressedKeys.containsKey(locationAwareKeyCode) &&
this._pressedKeys.get(locationAwareKeyCode)
);
}
/**
@@ -202,7 +167,10 @@ namespace gdjs {
* @param locationAwareKeyCode The location-aware key code to be tested.
*/
wasKeyReleased(locationAwareKeyCode: number) {
return !!this._releasedKeys.get(locationAwareKeyCode);
return (
this._releasedKeys.containsKey(locationAwareKeyCode) &&
this._releasedKeys.get(locationAwareKeyCode)
);
}
/**
@@ -576,7 +544,6 @@ namespace gdjs {
this._startedTouches.length = 0;
this._endedTouches.length = 0;
this._releasedKeys.clear();
this._justPressedKeys.clear();
this._releasedMouseButtons.length = 0;
this._mouseWheelDelta = 0;
this._lastStartedTouchIndex = 0;
@@ -597,6 +564,14 @@ namespace gdjs {
return this.getMouseWheelDelta() < 0;
}
/**
* Clears all stored pressed keys without making the keys go through
* the release state.
*/
clearAllPressedKeys(): void {
this._pressedKeys.clear();
}
static _allTouchIds: Array<integer> = [];
}
}

View File

@@ -209,16 +209,25 @@ namespace gdjs {
this._callbacks.clear();
}
unloadResource(resourceData: ResourceData): void {
const loadedJson = this._loadedJsons.get(resourceData);
if (loadedJson) {
this._loadedJsons.delete(resourceData);
}
/**
* Unload the specified list of resources:
* this clears the JSONs loaded in this manager.
*
* Usually called when scene resoures are unloaded.
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedJson = this._loadedJsons.get(resourceData);
if (loadedJson) {
this._loadedJsons.delete(resourceData);
}
const callback = this._callbacks.get(resourceData);
if (callback) {
this._callbacks.delete(resourceData);
}
const callback = this._callbacks.get(resourceData);
if (callback) {
this._callbacks.delete(resourceData);
}
});
}
}
}

View File

@@ -9,7 +9,6 @@ namespace gdjs {
*/
export class Layer extends gdjs.RuntimeLayer {
_cameraRotation: float = 0;
/** The camera zoom factor strictly greater than 0. */
_zoomFactor: float = 1;
_cameraX: float;
_cameraY: float;
@@ -167,9 +166,6 @@ namespace gdjs {
* @param cameraId The camera number. Currently ignored.
*/
override setCameraZoom(newZoom: float, cameraId?: integer): void {
if (newZoom <= 0) {
return;
}
this._zoomFactor = newZoom;
this._isCameraZDirty = true;
this._renderer.updatePosition();
@@ -287,8 +283,8 @@ namespace gdjs {
x -= this.getRuntimeScene()._cachedGameResolutionWidth / 2;
y -= this.getRuntimeScene()._cachedGameResolutionHeight / 2;
x /= this._zoomFactor;
y /= this._zoomFactor;
x /= Math.abs(this._zoomFactor);
y /= Math.abs(this._zoomFactor);
// Only compute angle and cos/sin once (allow heavy optimization from JS engines).
const angleInRadians = (this._cameraRotation / 180) * Math.PI;
@@ -324,8 +320,8 @@ namespace gdjs {
): FloatPoint {
x -= this._runtimeScene.getViewportOriginX();
y -= this._runtimeScene.getViewportOriginY();
x /= this._zoomFactor;
y /= this._zoomFactor;
x /= Math.abs(this._zoomFactor);
y /= Math.abs(this._zoomFactor);
// Only compute angle and cos/sin once (allow heavy optimization from JS engines).
const angleInRadians = (this._cameraRotation / 180) * Math.PI;
@@ -371,8 +367,8 @@ namespace gdjs {
const sinValue = Math.sin(-angleInRadians);
x = cosValue * x - sinValue * y;
y = sinValue * tmp + cosValue * y;
x *= this._zoomFactor;
y *= this._zoomFactor;
x *= Math.abs(this._zoomFactor);
y *= Math.abs(this._zoomFactor);
position[0] = x + this.getRuntimeScene()._cachedGameResolutionWidth / 2;
position[1] = y + this.getRuntimeScene()._cachedGameResolutionHeight / 2;
@@ -408,8 +404,8 @@ namespace gdjs {
const sinValue = Math.sin(-angleInRadians);
x = cosValue * x - sinValue * y;
y = sinValue * tmp + cosValue * y;
x *= this._zoomFactor;
y *= this._zoomFactor;
x *= Math.abs(this._zoomFactor);
y *= Math.abs(this._zoomFactor);
x += this._runtimeScene.getViewportOriginX();
y += this._runtimeScene.getViewportOriginY();

View File

@@ -308,21 +308,32 @@ namespace gdjs {
this._loadedFontsData.clear();
}
unloadResource(resourceData: ResourceData): void {
const loadedFont = this._loadedFontsData.get(resourceData);
if (loadedFont) {
this._loadedFontsData.delete(resourceData);
}
/**
* Unload the specified list of resources:
* this uninstalls fonts from memory and clear cache of loaded fonts.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
for (const bitmapFontInstallKey in this._pixiBitmapFontsInUse) {
if (bitmapFontInstallKey.endsWith(resourceData.file))
PIXI.BitmapFont.uninstall(bitmapFontInstallKey);
}
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedFont = this._loadedFontsData.get(resourceData);
if (loadedFont) {
this._loadedFontsData.delete(resourceData);
}
for (const bitmapFontInstallKey of this._pixiBitmapFontsToUninstall) {
if (bitmapFontInstallKey.endsWith(resourceData.file))
PIXI.BitmapFont.uninstall(bitmapFontInstallKey);
}
for (const bitmapFontInstallKey in this._pixiBitmapFontsInUse) {
if (bitmapFontInstallKey.endsWith(resourceData.file))
PIXI.BitmapFont.uninstall(bitmapFontInstallKey);
}
for (const bitmapFontInstallKey of this._pixiBitmapFontsToUninstall) {
if (bitmapFontInstallKey.endsWith(resourceData.file))
PIXI.BitmapFont.uninstall(bitmapFontInstallKey);
}
});
}
}

View File

@@ -55,7 +55,7 @@ namespace gdjs {
* Map associating a resource name to the loaded Three.js texture.
*/
private _loadedThreeTextures: Hashtable<THREE.Texture>;
private _loadedThreeMaterials = new ThreeMaterialCache();
private _loadedThreeMaterials: Hashtable<THREE.Material>;
private _diskTextures = new Map<float, PIXI.Texture>();
private _rectangleTextures = new Map<string, PIXI.Texture>();
@@ -73,6 +73,7 @@ namespace gdjs {
{ width: 192, height: 192 }
);
this._loadedThreeTextures = new Hashtable();
this._loadedThreeMaterials = new Hashtable();
}
getResourceKinds(): ResourceKind[] {
@@ -223,37 +224,38 @@ namespace gdjs {
*/
getThreeMaterial(
resourceName: string,
options: {
{
useTransparentTexture,
forceBasicMaterial,
vertexColors,
}: {
useTransparentTexture: boolean;
forceBasicMaterial: boolean;
vertexColors: boolean;
}
): THREE.Material {
const loadedThreeMaterial = this._loadedThreeMaterials.get(
resourceName,
options
);
const cacheKey = `${resourceName}|${useTransparentTexture ? 1 : 0}|${
forceBasicMaterial ? 1 : 0
}|${vertexColors ? 1 : 0}`;
const loadedThreeMaterial = this._loadedThreeMaterials.get(cacheKey);
if (loadedThreeMaterial) return loadedThreeMaterial;
const material = options.forceBasicMaterial
const material = forceBasicMaterial
? new THREE.MeshBasicMaterial({
map: this.getThreeTexture(resourceName),
side: options.useTransparentTexture
? THREE.DoubleSide
: THREE.FrontSide,
transparent: options.useTransparentTexture,
vertexColors: options.vertexColors,
side: useTransparentTexture ? THREE.DoubleSide : THREE.FrontSide,
transparent: useTransparentTexture,
vertexColors,
})
: new THREE.MeshStandardMaterial({
map: this.getThreeTexture(resourceName),
side: options.useTransparentTexture
? THREE.DoubleSide
: THREE.FrontSide,
transparent: options.useTransparentTexture,
side: useTransparentTexture ? THREE.DoubleSide : THREE.FrontSide,
transparent: useTransparentTexture,
metalness: 0,
vertexColors: options.vertexColors,
vertexColors,
});
this._loadedThreeMaterials.set(resourceName, options, material);
this._loadedThreeMaterials.put(cacheKey, material);
return material;
}
@@ -483,7 +485,12 @@ namespace gdjs {
threeTexture.dispose();
}
this._loadedThreeMaterials.disposeAll();
const threeMaterials: THREE.Material[] = [];
this._loadedThreeMaterials.values(threeMaterials);
this._loadedThreeMaterials.clear();
for (const threeMaterial of threeMaterials) {
threeMaterial.dispose();
}
for (const pixiTexture of this._diskTextures.values()) {
if (pixiTexture.destroyed) {
@@ -513,113 +520,35 @@ namespace gdjs {
this._scaledTextures.clear();
}
unloadResource(resourceData: ResourceData): void {
const resourceName = resourceData.name;
const texture = this._loadedTextures.getFromName(resourceName);
if (texture) {
texture.destroy(true);
this._loadedTextures.delete(resourceData);
}
const threeTexture = this._loadedThreeTextures.get(resourceName);
if (threeTexture) {
threeTexture.dispose();
this._loadedThreeTextures.remove(resourceName);
}
this._loadedThreeMaterials.dispose(resourceName);
}
}
class ThreeMaterialCache {
private _flaggedMaterials = new Map<string, THREE.Material>();
private _materialFlaggedKeys = new Map<string, Array<string>>();
/**
* Return the three.js material associated to the specified resource name
* and options.
* @param resourceName The name of the resource
* @param options
* @returns The requested material.
* Unload the specified list of resources:
* this clears the cache of loaded textures associated to these resources.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
get(
resourceName: string,
{
useTransparentTexture,
forceBasicMaterial,
vertexColors,
}: {
useTransparentTexture: boolean;
forceBasicMaterial: boolean;
vertexColors: boolean;
}
): THREE.Material | null {
const flaggedKey = `${resourceName}|${useTransparentTexture ? 1 : 0}|${
forceBasicMaterial ? 1 : 0
}|${vertexColors ? 1 : 0}`;
return this._flaggedMaterials.get(flaggedKey) || null;
}
/**
* Set the three.js material associated to the specified resource name
* and options.
* @param resourceName The name of the resource
* @param options
* @param material The material to add to the cache
*/
set(
resourceName: string,
{
useTransparentTexture,
forceBasicMaterial,
vertexColors,
}: {
useTransparentTexture: boolean;
forceBasicMaterial: boolean;
vertexColors: boolean;
},
material: THREE.Material
): void {
const cacheKey = `${resourceName}|${useTransparentTexture ? 1 : 0}|${
forceBasicMaterial ? 1 : 0
}|${vertexColors ? 1 : 0}`;
this._flaggedMaterials.set(cacheKey, material);
let flaggedKeys = this._materialFlaggedKeys.get(resourceName);
if (!flaggedKeys) {
flaggedKeys = [];
this._materialFlaggedKeys.set(resourceName, flaggedKeys);
}
flaggedKeys.push(cacheKey);
}
/**
* Delete and dispose all the three.js material associated to the specified
* resource name.
* @param resourceName The name of the resource
*/
dispose(resourceName: string): void {
const flaggedKeys = this._materialFlaggedKeys.get(resourceName);
if (flaggedKeys) {
for (const flaggedKey of flaggedKeys) {
const threeMaterial = this._flaggedMaterials.get(flaggedKey);
if (threeMaterial) {
threeMaterial.dispose();
}
this._flaggedMaterials.delete(flaggedKey);
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const resourceName = resourceData.name;
const resource = this._loadedTextures.get(resourceData);
if (resource) {
resource.destroy(true);
this._loadedTextures.delete(resourceData);
}
}
this._materialFlaggedKeys.delete(resourceName);
}
/**
* Delete and dispose all the three.js material in the cache.
*/
disposeAll(): void {
for (const material of this._flaggedMaterials.values()) {
material.dispose();
}
this._flaggedMaterials.clear();
this._materialFlaggedKeys.clear();
const threeTexture = this._loadedThreeTextures.get(resourceName);
if (threeTexture) {
threeTexture.dispose();
this._loadedThreeTextures.remove(resourceName);
}
const threeMaterials = this._loadedThreeMaterials.get(resourceName);
if (threeMaterials) {
threeMaterials.dispose();
this._loadedThreeMaterials.remove(resourceName);
}
});
}
}

View File

@@ -657,14 +657,6 @@ namespace gdjs {
e.preventDefault();
}
if (e.repeat) {
// If `repeat` is true, this is not the first press of the key.
// We only communicate the changes of states ("first" key down, key up)
// to the manager, which then tracks the state of the key:
// pressed, just pressed or released.
return;
}
manager.onKeyPressed(e.keyCode, e.location);
};
document.onkeyup = function (e) {

View File

@@ -140,18 +140,18 @@ namespace gdjs {
this._sprite.visible = !this._object.hidden;
}
setColor(rgbOrHexColor: string): void {
setColor(rgbOrHexColor): void {
this._sprite.tint = gdjs.rgbOrHexStringToNumber(rgbOrHexColor);
}
getColor() {
const rgb = new PIXI.Color(this._sprite.tint).toRgbArray();
return (
Math.round(rgb[0] * 255) +
Math.floor(rgb[0] * 255) +
';' +
Math.round(rgb[1] * 255) +
Math.floor(rgb[1] * 255) +
';' +
Math.round(rgb[2] * 255)
Math.floor(rgb[2] * 255)
);
}

View File

@@ -7,8 +7,6 @@ const {
} = require('./lib/runtime-files-list');
const args = require('minimist')(process.argv.slice(2), {
string: ['out'],
boolean: ['debug'],
default: { debug: false }
});
const fs = require('fs').promises;
@@ -54,7 +52,7 @@ shell.mkdir('-p', bundledOutPath);
return build({
sourcemap: true,
entryPoints: [inPath],
minify: !args.debug,
minify: true,
outfile: renameBuiltFile(outPath),
}).catch(() => {
// Error is already logged by esbuild.

View File

@@ -92,13 +92,15 @@ gdjs.MockedResourceManager = class MockedResourceManager {
}
/**
* Dispose specific resource
* Dispose specific resources
*/
unloadResource(resource) {
this.disposedResources.add(resource.name);
this.loadedResources.delete(resource.name);
this.loadResourceCallbacks.delete(resource.name);
this.loadResourcePromises.delete(resource.name);
unloadResourcesList(resourcesList) {
for (const resource of resourcesList) {
this.disposedResources.add(resource.name);
this.loadedResources.delete(resource.name);
this.loadResourceCallbacks.delete(resource.name);
this.loadResourcePromises.delete(resource.name);
}
}
/**
@@ -107,4 +109,4 @@ gdjs.MockedResourceManager = class MockedResourceManager {
getResourceKinds() {
return ['fake-resource-kind-for-testing-only'];
}
};
}

View File

@@ -36,11 +36,11 @@ describe('gdjs', function () {
expect(gdjs.rgbOrHexToRGBColor('255;255;300')).to.eql([255, 255, 255]);
expect(gdjs.rgbOrHexToRGBColor('999;12;6')).to.eql([255, 12, 6]);
});
it('should cap rgb values', function () {
it('should cut rgb values if string too long', function () {
expect(gdjs.rgbOrHexToRGBColor('255;255;200456')).to.eql([
255,
255,
255,
200,
]);
});
it('should return components for black if unrecognized input', function () {
@@ -48,6 +48,7 @@ describe('gdjs', function () {
expect(gdjs.rgbOrHexToRGBColor('19819830803')).to.eql([0, 0, 0]);
expect(gdjs.rgbOrHexToRGBColor('Infinity')).to.eql([0, 0, 0]);
expect(gdjs.rgbOrHexToRGBColor('-4564')).to.eql([0, 0, 0]);
expect(gdjs.rgbOrHexToRGBColor('9999;12;6')).to.eql([0, 0, 0]);
});
});
});

View File

@@ -34,14 +34,9 @@ describe('gdjs.InputManager', () => {
inputManager.onKeyPressed(33);
expect(inputManager.getLastPressedKey()).to.be(33);
expect(inputManager.isKeyPressed(32)).to.be(true);
expect(inputManager.wasKeyJustPressed(32)).to.be(true);
expect(inputManager.isKeyPressed(30)).to.be(false);
expect(inputManager.wasKeyJustPressed(30)).to.be(false);
expect(inputManager.isKeyPressed(33)).to.be(true);
expect(inputManager.wasKeyJustPressed(33)).to.be(true);
inputManager.onKeyReleased(32);
expect(inputManager.isKeyPressed(32)).to.be(false);
expect(inputManager.wasKeyJustPressed(32)).to.be(false);
expect(inputManager.wasKeyReleased(32)).to.be(true);
expect(inputManager.anyKeyReleased()).to.be(true);
expect(inputManager.anyKeyPressed()).to.be(true);
@@ -51,14 +46,10 @@ describe('gdjs.InputManager', () => {
expect(inputManager.anyKeyPressed()).to.be(true);
expect(inputManager.anyKeyReleased()).to.be(false);
expect(inputManager.isKeyPressed(33)).to.be(true);
expect(inputManager.wasKeyJustPressed(33)).to.be(false);
expect(inputManager.wasKeyJustPressed(32)).to.be(false);
inputManager.onFrameEnded();
inputManager.onKeyReleased(33);
expect(inputManager.wasKeyReleased(33)).to.be(true);
expect(inputManager.wasKeyJustPressed(33)).to.be(false);
expect(inputManager.wasKeyJustPressed(32)).to.be(false);
expect(inputManager.anyKeyPressed()).to.be(false);
expect(inputManager.anyKeyReleased()).to.be(true);
inputManager.onFrameEnded();
@@ -70,16 +61,12 @@ describe('gdjs.InputManager', () => {
expect(inputManager.getLastPressedKey()).to.be(2016);
expect(inputManager.isKeyPressed(2016)).to.be(true);
expect(inputManager.isKeyPressed(1016)).to.be(false);
expect(inputManager.wasKeyJustPressed(2016)).to.be(true);
expect(inputManager.wasKeyJustPressed(1016)).to.be(false);
// Pressed Control with no location - expect to default to left.
inputManager.onKeyPressed(17);
expect(inputManager.getLastPressedKey()).to.be(1017);
expect(inputManager.isKeyPressed(1017)).to.be(true);
expect(inputManager.isKeyPressed(2017)).to.be(false);
expect(inputManager.wasKeyJustPressed(1017)).to.be(true);
expect(inputManager.wasKeyJustPressed(2017)).to.be(false);
inputManager.onKeyReleased(16, 2);
expect(inputManager.wasKeyReleased(2016)).to.be(true);

View File

@@ -1143,7 +1143,6 @@ interface LayersContainer {
boolean HasLayerNamed([Const] DOMString name);
void RemoveLayer([Const] DOMString name);
unsigned long GetLayersCount();
unsigned long GetLayerPosition([Const] DOMString name);
void SwapLayers(unsigned long firstLayerIndex, unsigned long secondLayerIndex);
void MoveLayer(unsigned long oldIndex, unsigned long newIndex);
void SerializeLayersTo([Ref] SerializerElement element);
@@ -2145,9 +2144,6 @@ interface BehaviorMetadata {
boolean IsHidden();
[Ref] BehaviorMetadata SetHidden();
boolean IsRelevantForChildObjects();
[Ref] BehaviorMetadata MarkAsIrrelevantForChildObjects();
QuickCustomization_Visibility GetQuickCustomizationVisibility();
[Ref] BehaviorMetadata SetQuickCustomizationVisibility(QuickCustomization_Visibility visibility);
@@ -2936,7 +2932,6 @@ interface MetadataProvider {
boolean STATIC_IsBadInstructionMetadata([Const, Ref] InstructionMetadata metadata);
boolean STATIC_IsBadBehaviorMetadata([Const, Ref] BehaviorMetadata metadata);
boolean STATIC_IsBadObjectMetadata([Const, Ref] ObjectMetadata metadata);
boolean STATIC_IsBadEffectMetadata([Const, Ref] EffectMetadata metadata);
};
enum ProjectDiagnostic_ErrorType {
@@ -3733,8 +3728,6 @@ interface TextObject {
[Const, Ref] DOMString GetText();
void SetCharacterSize(double size);
double GetCharacterSize();
void SetLineHeight(double value);
double GetLineHeight();
void SetFontName([Const] DOMString string);
[Const, Ref] DOMString GetFontName();
boolean IsBold();

View File

@@ -606,7 +606,6 @@ typedef std::vector<gd::PropertyDescriptorChoice> VectorPropertyDescriptorChoice
#define STATIC_IsBadInstructionMetadata IsBadInstructionMetadata
#define STATIC_IsBadBehaviorMetadata IsBadBehaviorMetadata
#define STATIC_IsBadObjectMetadata IsBadObjectMetadata
#define STATIC_IsBadEffectMetadata IsBadEffectMetadata
#define STATIC_RenameObjectInEvents RenameObjectInEvents
#define STATIC_RemoveObjectInEvents RemoveObjectInEvents

View File

@@ -64,7 +64,7 @@ describe('libGD.js object serialization', function() {
obj.delete();
expect(jsonObject).toBe(
'{"bold":false,"italic":false,"smoothed":true,"underlined":false,"string":"Text of the object, with 官话 characters","font":"","textAlignment":"left","characterSize":20.0,"color":{"b":0,"g":0,"r":0},"content":{"bold":false,"isOutlineEnabled":false,"isShadowEnabled":false,"italic":false,"outlineColor":"255;255;255","outlineThickness":2.0,"shadowAngle":90.0,"shadowBlurRadius":2.0,"shadowColor":"0;0;0","shadowDistance":4.0,"shadowOpacity":127.0,"smoothed":true,"underlined":false,"text":"Text of the object, with 官话 characters","font":"","textAlignment":"left","verticalTextAlignment":"top","characterSize":20.0,"lineHeight":0.0,"color":"0;0;0"}}'
'{"bold":false,"italic":false,"smoothed":true,"underlined":false,"string":"Text of the object, with 官话 characters","font":"","textAlignment":"left","characterSize":20.0,"color":{"b":0,"g":0,"r":0},"content":{"bold":false,"isOutlineEnabled":false,"isShadowEnabled":false,"italic":false,"outlineColor":"255;255;255","outlineThickness":2.0,"shadowAngle":90.0,"shadowBlurRadius":2.0,"shadowColor":"0;0;0","shadowDistance":4.0,"shadowOpacity":127.0,"smoothed":true,"underlined":false,"text":"Text of the object, with 官话 characters","font":"","textAlignment":"left","verticalTextAlignment":"top","characterSize":20.0,"color":"0;0;0"}}'
);
});
});

View File

@@ -941,7 +941,6 @@ export class LayersContainer extends EmscriptenObject {
hasLayerNamed(name: string): boolean;
removeLayer(name: string): void;
getLayersCount(): number;
getLayerPosition(name: string): number;
swapLayers(firstLayerIndex: number, secondLayerIndex: number): void;
moveLayer(oldIndex: number, newIndex: number): void;
serializeLayersTo(element: SerializerElement): void;
@@ -1671,8 +1670,6 @@ export class BehaviorMetadata extends EmscriptenObject {
setPrivate(): BehaviorMetadata;
isHidden(): boolean;
setHidden(): BehaviorMetadata;
isRelevantForChildObjects(): boolean;
markAsIrrelevantForChildObjects(): BehaviorMetadata;
getQuickCustomizationVisibility(): QuickCustomization_Visibility;
setQuickCustomizationVisibility(visibility: QuickCustomization_Visibility): BehaviorMetadata;
setOpenFullEditorLabel(label: string): BehaviorMetadata;
@@ -2112,7 +2109,6 @@ export class MetadataProvider extends EmscriptenObject {
static isBadInstructionMetadata(metadata: InstructionMetadata): boolean;
static isBadBehaviorMetadata(metadata: BehaviorMetadata): boolean;
static isBadObjectMetadata(metadata: ObjectMetadata): boolean;
static isBadEffectMetadata(metadata: EffectMetadata): boolean;
}
export class ProjectDiagnostic extends EmscriptenObject {
@@ -2762,8 +2758,6 @@ export class TextObject extends ObjectConfiguration {
getText(): string;
setCharacterSize(size: number): void;
getCharacterSize(): number;
setLineHeight(value: number): void;
getLineHeight(): number;
setFontName(string: string): void;
getFontName(): string;
isBold(): boolean;

View File

@@ -33,8 +33,6 @@ declare class gdBehaviorMetadata {
setPrivate(): gdBehaviorMetadata;
isHidden(): boolean;
setHidden(): gdBehaviorMetadata;
isRelevantForChildObjects(): boolean;
markAsIrrelevantForChildObjects(): gdBehaviorMetadata;
getQuickCustomizationVisibility(): QuickCustomization_Visibility;
setQuickCustomizationVisibility(visibility: QuickCustomization_Visibility): gdBehaviorMetadata;
setOpenFullEditorLabel(label: string): gdBehaviorMetadata;

View File

@@ -7,7 +7,6 @@ declare class gdLayersContainer {
hasLayerNamed(name: string): boolean;
removeLayer(name: string): void;
getLayersCount(): number;
getLayerPosition(name: string): number;
swapLayers(firstLayerIndex: number, secondLayerIndex: number): void;
moveLayer(oldIndex: number, newIndex: number): void;
serializeLayersTo(element: gdSerializerElement): void;

View File

@@ -26,7 +26,6 @@ declare class gdMetadataProvider {
static isBadInstructionMetadata(metadata: gdInstructionMetadata): boolean;
static isBadBehaviorMetadata(metadata: gdBehaviorMetadata): boolean;
static isBadObjectMetadata(metadata: gdObjectMetadata): boolean;
static isBadEffectMetadata(metadata: gdEffectMetadata): boolean;
delete(): void;
ptr: number;
};

View File

@@ -5,8 +5,6 @@ declare class gdTextObject extends gdObjectConfiguration {
getText(): string;
setCharacterSize(size: number): void;
getCharacterSize(): number;
setLineHeight(value: number): void;
getLineHeight(): number;
setFontName(string: string): void;
getFontName(): string;
isBold(): boolean;

View File

@@ -33,6 +33,9 @@ Images resources, GDJS Runtime, extensions will be copied in resources, and [lib
You can run the standalone app with Electron. **Make sure that you've launched `npm start` (or `yarn start`) in the `app` folder before** (see above) and **keep it running** (in development, the app is served from a local server, even for the standalone app).
> Note for Windows: With **Node.js 14 or older**, there is an error related to `git-sh-setup` when running npm install.
> To solve this problem: add [this folder to your path environment variable](https://stackoverflow.com/questions/49256190/how-to-fix-git-sh-setup-file-not-found-in-windows) **OR** run `npm install` in newIDE/electron-app/app **before** npm install in newIDE/electron-app.
```bash
cd newIDE/app && npm start # Be sure to have this running in another terminal, before the rest!
@@ -78,11 +81,17 @@ It's pretty easy to create new themes. Check the [README about themes](./README-
### Development of the game engine or extensions
- If you want to create/modify _extensions_, check the [README about extensions](./README-extensions.md) for step-by-step explanations to get started in 5 minutes.
- The _game engine core_ ([GDJS](https://github.com/4ian/GDevelop/tree/master/GDJS)) is in [GDJS/Runtime folder](https://github.com/4ian/GDevelop/tree/master/GDJS/Runtime).
Make sure to have the standalone app running with Electron.
If you modify any file while the editor is running, a watcher will _automatically rebuild_ the engine (look at the console to be sure).
You can then _launch a preview_ in GDevelop.
- If you want to create/modify _extensions_, check the [README about extensions](./README-extensions.md) for step-by-step explanations to get started in 5 minutes.
- The _game engine core_ ([GDJS](https://github.com/4ian/GDevelop/tree/master/GDJS)) is in [GDJS/Runtime folder](https://github.com/4ian/GDevelop/tree/master/GDJS/Runtime).
If you modify any file while the IDE is running with Electron, a watcher will _automatically import_ your changes (look at the console to be sure).
You can then _launch a preview_ in GDevelop (again, be sure to be using [the standalone app running with Electron](https://github.com/4ian/GDevelop/blob/master/newIDE/README.md#development-of-the-standalone-app) to be sure to have your changes reflected immediately).
> If you deactivated the watcher in preferences, run the `import-GDJS-Runtime.js` script manually (`cd newIDE/app/scripts` then `node import-GDJS-Runtime.js`) after every change, before launching a preview.
### Recommended tools for development

View File

@@ -33,14 +33,11 @@ export const parameters = {
// that we don't use.
controls: { hideNoControlsWarning: true },
docs: { disable: true },
mockAddonConfigs: {
globalMockData: [],
},
};
export const decorators = [
themeDecorator,
GDevelopJsInitializerDecorator,
i18nProviderDecorator,
BrowserDropDownMenuDisablerDecorator,
];
BrowserDropDownMenuDisablerDecorator
]

View File

@@ -81,13 +81,10 @@
"@storybook/react": "7.4.6",
"@storybook/react-webpack5": "7.4.6",
"@storybook/theming": "7.4.6",
"@types/node": "22.18.0",
"adm-zip": "^0.5.10",
"axios-mock-adapter": "^1.22.0",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "8.1.0",
"chokidar": "4.0.3",
"concurrently": "9.2.1",
"flow-bin": "0.131.0",
"flow-coverage-report": "^0.4.0",
"folder-hash": "^3.0.0",
@@ -99,7 +96,6 @@
"react-scripts": "5.0.1",
"recursive-copy": "^2.0.14",
"recursive-readdir": "^2.2.2",
"serve-handler": "6.1.6",
"shelljs": "0.8.4",
"storybook": "7.4.6",
"storybook-addon-mock": "4.3.0",
@@ -7069,12 +7065,6 @@
}
}
},
"node_modules/@storybook/builder-webpack5/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/builder-webpack5/node_modules/ajv": {
"version": "8.12.0",
"dev": true,
@@ -8063,12 +8053,6 @@
"url": "https://opencollective.com/storybook"
}
},
"node_modules/@storybook/core-common/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/core-common/node_modules/brace-expansion": {
"version": "2.0.1",
"dev": true,
@@ -8391,12 +8375,6 @@
"url": "https://opencollective.com/storybook"
}
},
"node_modules/@storybook/core-server/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/core-server/node_modules/fs-extra": {
"version": "11.1.1",
"dev": true,
@@ -8568,12 +8546,6 @@
"url": "https://opencollective.com/storybook"
}
},
"node_modules/@storybook/core-webpack/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/csf": {
"version": "0.1.1",
"dev": true,
@@ -8871,12 +8843,6 @@
}
}
},
"node_modules/@storybook/preset-react-webpack/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/preset-react-webpack/node_modules/fs-extra": {
"version": "11.1.1",
"dev": true,
@@ -9101,18 +9067,6 @@
}
}
},
"node_modules/@storybook/react-webpack5/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/react/node_modules/@types/node": {
"version": "16.18.126",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz",
"integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==",
"dev": true
},
"node_modules/@storybook/react/node_modules/acorn": {
"version": "7.4.1",
"dev": true,
@@ -10101,12 +10055,8 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dependencies": {
"undici-types": "~6.21.0"
}
"version": "16.18.12",
"license": "MIT"
},
"node_modules/@types/node-fetch": {
"version": "2.6.6",
@@ -12249,15 +12199,11 @@
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"version": "2.1.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bl": {
@@ -12766,18 +12712,29 @@
"license": "MIT"
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"version": "3.5.3",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 14.16.0"
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
@@ -13196,171 +13153,6 @@
"typedarray": "^0.0.6"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/concurrently/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/concurrently/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/concurrently/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"node_modules/concurrently/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/concurrently/node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/concurrently/node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/concurrently/node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"dev": true,
@@ -16987,30 +16779,6 @@
"webpack": "^5.11.0"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": {
"version": "7.1.0",
"dev": true,
@@ -17082,18 +16850,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -18608,9 +18364,8 @@
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
@@ -22858,6 +22613,14 @@
"shell-quote": "^1.7.3"
}
},
"node_modules/launch-editor/node_modules/shell-quote": {
"version": "1.8.1",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/lazy-universal-dotenv": {
"version": "4.0.0",
"dev": true,
@@ -25157,12 +24920,6 @@
"node": ">=0.10.0"
}
},
"node_modules/path-is-inside": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
"integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==",
"dev": true
},
"node_modules/path-key": {
"version": "2.0.1",
"dev": true,
@@ -27043,30 +26800,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-scripts/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/react-scripts/node_modules/cosmiconfig": {
"version": "7.1.0",
"dev": true,
@@ -28785,18 +28518,6 @@
"node": ">= 12.13.0"
}
},
"node_modules/react-scripts/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/react-scripts/node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -28900,6 +28621,14 @@
"node": ">=8"
}
},
"node_modules/react-scripts/node_modules/shell-quote": {
"version": "1.8.1",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/react-scripts/node_modules/source-map": {
"version": "0.6.1",
"dev": true,
@@ -29382,16 +29111,14 @@
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"version": "3.6.0",
"dev": true,
"engines": {
"node": ">= 14.18.0"
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/recast": {
@@ -30362,87 +30089,6 @@
"randombytes": "^2.1.0"
}
},
"node_modules/serve-handler": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz",
"integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==",
"dev": true,
"dependencies": {
"bytes": "3.0.0",
"content-disposition": "0.5.2",
"mime-types": "2.1.18",
"minimatch": "3.1.2",
"path-is-inside": "1.0.2",
"path-to-regexp": "3.3.0",
"range-parser": "1.2.0"
}
},
"node_modules/serve-handler/node_modules/bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
"integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==",
"dev": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/serve-handler/node_modules/content-disposition": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
"integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-handler/node_modules/mime-db": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
"integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-handler/node_modules/mime-types": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
"integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
"dev": true,
"dependencies": {
"mime-db": "~1.33.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-handler/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/serve-handler/node_modules/path-to-regexp": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz",
"integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==",
"dev": true
},
"node_modules/serve-handler/node_modules/range-parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
"integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/serve-index": {
"version": "1.9.1",
"dev": true,
@@ -30552,18 +30198,6 @@
"node": ">=0.10.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/shelljs": {
"version": "0.8.4",
"dev": true,
@@ -31696,42 +31330,6 @@
"node": ">= 8"
}
},
"node_modules/tailwindcss/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tailwindcss/node_modules/fast-glob": {
"version": "3.2.12",
"dev": true,
@@ -31876,18 +31474,6 @@
"postcss": "^8.2.14"
}
},
"node_modules/tailwindcss/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.3.1",
"dev": true,
@@ -32325,15 +31911,6 @@
"node": ">=6"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"license": "MIT",
@@ -32542,11 +32119,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
"dev": true,
@@ -32727,42 +32299,6 @@
"webpack-virtual-modules": "^0.5.0"
}
},
"node_modules/unplugin/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/unplugin/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/unplugin/node_modules/webpack-sources": {
"version": "3.2.3",
"dev": true,
@@ -33320,30 +32856,6 @@
"ajv": "^8.8.2"
}
},
"node_modules/webpack-dev-server/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.1.0",
"dev": true,
@@ -33373,18 +32885,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/webpack-dev-server/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/webpack-dev-server/node_modules/rimraf": {
"version": "3.0.2",
"dev": true,

View File

@@ -17,13 +17,10 @@
"@storybook/react": "7.4.6",
"@storybook/react-webpack5": "7.4.6",
"@storybook/theming": "7.4.6",
"@types/node": "22.18.0",
"adm-zip": "^0.5.10",
"axios-mock-adapter": "^1.22.0",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "8.1.0",
"chokidar": "4.0.3",
"concurrently": "9.2.1",
"flow-bin": "0.131.0",
"flow-coverage-report": "^0.4.0",
"folder-hash": "^3.0.0",
@@ -35,7 +32,6 @@
"react-scripts": "5.0.1",
"recursive-copy": "^2.0.14",
"recursive-readdir": "^2.2.2",
"serve-handler": "6.1.6",
"shelljs": "0.8.4",
"storybook": "7.4.6",
"storybook-addon-mock": "4.3.0",
@@ -120,7 +116,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\"",
"start": "npm run import-resources && npm run make-version-metadata && react-app-rewired start",
"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

@@ -632,56 +632,6 @@ const generateExtensionRawText = (
...expressionsReferenceTexts,
];
}),
{ text: '' },
...extension
.getExtensionEffectTypes()
.toJSArray()
.map(
/**
* @param {string} effectType
* @returns {RawText}
*/
effectType => {
const effectMetadata = extension.getEffectMetadata(effectType);
const properties = effectMetadata.getProperties();
const propertyNames = properties.keys().toJSArray();
return {
text: [
`### Effect "${effectMetadata.getFullName()}"`,
'',
`${effectMetadata.getDescription().replace(/\n/g, ' ')}`,
'',
...[
effectMetadata.isMarkedAsUnique()
? 'This effect can be added only once on a layer.'
: null,
effectMetadata.isMarkedAsOnlyWorkingFor2D()
? effectMetadata.isMarkedAsNotWorkingForObjects()
? 'This effect is for 2D layers only.'
: 'This effect is for 2D layers or objects only.'
: null,
effectMetadata.isMarkedAsOnlyWorkingFor3D()
? 'This effect is for 3D layers only.'
: null,
].filter(Boolean),
'',
`Properties of this effect are:`,
'',
...propertyNames.map(propertyName => {
const propertyMetadata = properties.get(propertyName);
return [
propertyMetadata.getDescription()
? `- **${propertyMetadata.getLabel()}**: ${propertyMetadata.getDescription()}.`
: `- **${propertyMetadata.getLabel()}**.`,
`Default value is \`${propertyMetadata.getValue()}\`. For events, write: \`"${propertyName}"\`.`,
].join(' ');
}),
'',
].join(`\n`),
};
}
),
generateExtensionFooterText({ extension }),
].filter(Boolean);
};

View File

@@ -1,239 +0,0 @@
#!/usr/bin/env node
// @ts-check
/*
* This script watches GDJS Runtime and Extensions files for changes
* and automatically runs import-GDJS-Runtime.js when changes are detected.
*
* Usage: node watch-GDJS-runtime.js
* Stop with Ctrl+C
*/
const chokidar = require('chokidar');
const debounce = require('lodash.debounce');
const child_process = require('child_process');
const path = require('path');
const handler = require('serve-handler');
const http = require('http');
const PORT = 5002;
const gdevelopRootPath = path.join(__dirname, '..', '..', '..');
const gdjsRootPath = path.join(__dirname, '..', 'resources', 'GDJS');
// Serve the built files for GDJS.
const server = http.createServer((request, response) => {
return handler(request, response, {
public: gdjsRootPath,
cleanUrls: false,
headers: [
{
source: '**/*',
headers: [
// Tell the browser not to cache the files:
{
key: 'Cache-Control',
value: 'no-cache',
},
// Allow CORS because the web-app is served from a different origin:
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
{
key: 'Access-Control-Allow-Headers',
value: '*',
},
{
key: 'Access-Control-Allow-Credentials',
value: 'true',
},
{
key: 'Access-Control-Allow-Private-Network',
value: 'true',
},
],
},
],
});
});
/**
* Launch the import-GDJS-Runtime script.
* Cleaning the GDJS output folder and copying sources are both
* skipped to speed up the build.
* @returns {Promise<void>}
*/
const importGDJSRuntime = () => {
return new Promise((resolve, reject) => {
const startTime = performance.now();
const scriptPath = path.join(__dirname, 'import-GDJS-Runtime.js');
console.log('🔄 Running import-GDJS-Runtime.js...');
child_process.exec(
`node "${scriptPath}" --skip-clean --skip-sources`,
(error, stdout, stderr) => {
if (error) {
console.error(`❌ GDJS Runtime update error: ${error.message}`);
reject(error);
return;
}
if (stdout) {
console.log(stdout);
}
if (stderr) {
console.error(`⚠️ GDJS Runtime update warnings: ${stderr}`);
}
const duration = (performance.now() - startTime).toFixed(0);
console.log(`✅ GDJS Runtime updated in ${duration}ms.`);
resolve();
}
);
});
};
/**
* Callback for file changes, debounced to avoid running too frequently
*/
const onWatchEvent = debounce(
(event, filePath) => {
const eventName = event || 'unknown-event';
const resolvedFilename =
path.relative(gdevelopRootPath, filePath) || 'unknown-file';
console.log(
`📁 Detected "${eventName}" in ${resolvedFilename}, updating GDJS Runtime...`
);
importGDJSRuntime().catch(error => {
console.error('❌ Failed to update GDJS Runtime:', error.message);
});
},
100 // Avoid running the script too much in case multiple changes are fired at the same time
);
/**
* Set up file watchers for GDJS and Extensions sources
*/
const setupWatcher = () => {
const watchPaths = [
path.join(gdevelopRootPath, 'GDJS', 'Runtime'),
path.join(gdevelopRootPath, 'Extensions'),
];
const watcher = chokidar.watch(watchPaths, {
ignored: (filePath, stats) => {
if (!stats) return false;
if (!stats.isFile()) return false;
const ignoredExtensions = [
'.d.ts',
'.map',
'.md',
'.png',
'.svg',
'.svgz',
'.txt',
'.DS_Store',
'.prettierrc',
];
const ignoredFolderNames = ['tests', 'example', 'diagrams'];
if (ignoredExtensions.some(ext => filePath.endsWith(ext))) return true;
if (
ignoredFolderNames.some(
folder =>
filePath.includes(folder + '/') || filePath.includes(folder + '\\')
)
)
return true;
// Uncomment to log all watched files:
// console.log('Watched file path: ' + filePath);
return false;
},
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 50,
},
ignoreInitial: true,
});
// Set up event handlers:
const startTime = performance.now();
watcher
.on('all', onWatchEvent)
.on('error', error => {
console.error(
'❌ GDJS watcher error. Please try to relaunch it. Full error is:',
error
);
})
.on('ready', () => {
const duration = (performance.now() - startTime).toFixed(2);
console.log(`✅ GDJS watcher ready in ${duration}ms.`);
console.log(
` Watching GDJS engine files for changes in:\n${watchPaths
.map(path => `- ${path}`)
.join('\n')}.`
);
});
return watcher;
};
const watcher = setupWatcher();
// Handle graceful shutdown:
const shutdown = () => {
console.log('\n Shutting GDJS Runtime file watcher and server...');
server.close(() => {
console.log('✅ GDJS Runtime server closed.');
});
watcher
.close()
.then(() => {
console.log('✅ GDJS Runtime watcher closed.');
process.exit(0);
})
.catch(error => {
console.error('❌ Error closing GDJS Runtime watcher:', error);
process.exit(1);
});
};
// Handle Ctrl+C and other termination signals.
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('SIGHUP', shutdown);
// Start the server (used to serve the game engine to the web-app).
server.on('error', error => {
// @ts-ignore
if (error.code === 'EADDRINUSE') {
console.error(
`❌ Error: Port ${PORT} is already in use and can't be used to serve the game engine to the web-app.`
);
}
// @ts-ignore
else if (error.code === 'EACCES') {
console.error(
`❌ Error: Permission denied to bind to port ${PORT} and can't be used to serve the game engine to the web-app.`
);
} else {
console.error('❌ Game engine server error:', error);
}
process.exit(1);
});
server.listen(PORT, () => {
console.log(
` GDJS Runtime served for the web-app at http://localhost:${PORT}.`
);
});

View File

@@ -1,62 +0,0 @@
// @flow
import { type Limits } from '../Utils/GDevelopServices/Usage';
import {
type AiConfigurationPreset,
type AiSettings,
} from '../Utils/GDevelopServices/Generation';
export type AiConfigurationPresetWithAvailability = {|
...AiConfigurationPreset,
disabled: boolean,
enableWith: 'higher-tier-plan' | null,
|};
export const getAiConfigurationPresetsWithAvailability = ({
getAiSettings,
limits,
}: {|
getAiSettings: () => AiSettings | null,
limits: ?Limits,
|}): Array<AiConfigurationPresetWithAvailability> => {
const aiSettings = getAiSettings();
if (!aiSettings) {
return [];
}
if (!limits) {
return aiSettings.aiRequest.presets.map(preset => ({
...preset,
enableWith: null,
}));
}
return aiSettings.aiRequest.presets.map(preset => {
const presetAvailability = limits.capabilities.ai.availablePresets.find(
presetAvailability =>
presetAvailability.id === preset.id &&
presetAvailability.mode === preset.mode
);
return {
...preset,
disabled:
presetAvailability && presetAvailability.disabled !== undefined
? presetAvailability.disabled
: preset.disabled,
enableWith: (presetAvailability && presetAvailability.enableWith) || null,
};
});
};
export const getDefaultAiConfigurationPresetId = (
aiConfigurationPresetsWithAvailability: Array<AiConfigurationPresetWithAvailability>
): string => {
const defaultPresetWithAvailability = aiConfigurationPresetsWithAvailability.find(
preset => preset.isDefault
);
return (
(defaultPresetWithAvailability && defaultPresetWithAvailability.id) ||
'default'
);
};

View File

@@ -1,69 +0,0 @@
// @flow
import React from 'react';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import CompactSelectField from '../../UI/CompactSelectField';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import SelectOption from '../../UI/SelectOption';
import { type AiConfigurationPresetWithAvailability } from '../AiConfiguration';
type AiConfigurationPresetSelectorProps = {
chosenOrDefaultAiConfigurationPresetId: string,
setAiConfigurationPresetId: string => void,
aiConfigurationPresetsWithAvailability: Array<AiConfigurationPresetWithAvailability>,
aiRequestMode: string,
};
export const AiConfigurationPresetSelector = ({
chosenOrDefaultAiConfigurationPresetId,
setAiConfigurationPresetId,
aiConfigurationPresetsWithAvailability,
aiRequestMode,
}: AiConfigurationPresetSelectorProps) => {
const filteredAiConfigurationPresets = aiConfigurationPresetsWithAvailability.filter(
preset => preset.mode === aiRequestMode
);
const noUpgradeAiConfigurationPresets = filteredAiConfigurationPresets.filter(
preset => !preset.disabled || preset.enableWith !== 'higher-tier-plan'
);
const upgradeAiConfigurationPresets = filteredAiConfigurationPresets.filter(
preset => preset.disabled && preset.enableWith === 'higher-tier-plan'
);
return (
<I18n>
{({ i18n }) => (
<CompactSelectField
value={chosenOrDefaultAiConfigurationPresetId}
onChange={value => {
setAiConfigurationPresetId(value);
}}
>
{noUpgradeAiConfigurationPresets.map(preset => (
<SelectOption
key={preset.id}
value={preset.id}
label={selectMessageByLocale(i18n, preset.nameByLocale)}
disabled={preset.disabled}
shouldNotTranslate
/>
))}
{upgradeAiConfigurationPresets.length > 0 && (
<optgroup key={`upgrade`} label={i18n._(t`Upgrade for:`)}>
{upgradeAiConfigurationPresets.map(preset => (
<SelectOption
key={preset.id}
value={preset.id}
label={selectMessageByLocale(i18n, preset.nameByLocale)}
disabled={preset.disabled}
shouldNotTranslate
/>
))}
</optgroup>
)}
</CompactSelectField>
)}
</I18n>
);
};

View File

@@ -38,11 +38,6 @@ import { ChatMessages } from './ChatMessages';
import Send from '../../UI/CustomSvgIcons/Send';
import { FeedbackBanner } from './FeedbackBanner';
import classNames from 'classnames';
import {
type AiConfigurationPresetWithAvailability,
getDefaultAiConfigurationPresetId,
} from '../AiConfiguration';
import { AiConfigurationPresetSelector } from './AiConfigurationPresetSelector';
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
@@ -70,7 +65,6 @@ type Props = {
onStartNewAiRequest: (options: {|
userRequest: string,
mode: 'chat' | 'agent',
aiConfigurationPresetId: string,
|}) => void,
onSendMessage: (options: {|
userMessage: string,
@@ -91,7 +85,6 @@ type Props = {
aiRequestId: string | null,
|}) => void,
initialMode?: 'chat' | 'agent',
aiConfigurationPresetsWithAvailability: Array<AiConfigurationPresetWithAvailability>,
onProcessFunctionCalls: (
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
@@ -258,7 +251,6 @@ const getPriceText = ({
export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
(
{
aiConfigurationPresetsWithAvailability,
project,
aiRequest,
isSending,
@@ -296,41 +288,6 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
},
[initialMode]
);
const [
aiConfigurationPresetId,
setAiConfigurationPresetId,
] = React.useState<string | null>(null);
React.useEffect(
() => {
if (!aiConfigurationPresetsWithAvailability.length) return;
if (!aiConfigurationPresetId) return;
if (
aiConfigurationPresetsWithAvailability.find(
preset =>
preset.id === aiConfigurationPresetId &&
preset.mode === newAiRequestMode
)
) {
return;
}
// The selected preset is not a valid choice for the current mode - reset it.
console.info(
"Reset the AI configuration preset because it's not valid for the current mode."
);
setAiConfigurationPresetId(null);
},
[
newAiRequestMode,
aiConfigurationPresetsWithAvailability,
aiConfigurationPresetId,
]
);
const aiRequestId: string = aiRequest ? aiRequest.id : '';
const [
userRequestTextPerAiRequestId,
@@ -464,10 +421,6 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
</Text>
);
const chosenOrDefaultAiConfigurationPresetId =
aiConfigurationPresetId ||
getDefaultAiConfigurationPresetId(aiConfigurationPresetsWithAvailability);
if (!aiRequest) {
return (
<div
@@ -516,7 +469,6 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onStartNewAiRequest({
mode: newAiRequestMode,
userRequest: userRequestTextPerAiRequestId[''],
aiConfigurationPresetId: chosenOrDefaultAiConfigurationPresetId,
});
}}
>
@@ -542,7 +494,6 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onStartNewAiRequest({
mode: newAiRequestMode,
userRequest: userRequestTextPerAiRequestId[''],
aiConfigurationPresetId: chosenOrDefaultAiConfigurationPresetId,
});
}}
placeholder={newChatPlaceholder}
@@ -551,20 +502,8 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
<Column>
<LineStackLayout
alignItems="center"
justifyContent="space-between"
justifyContent="flex-end"
>
<AiConfigurationPresetSelector
chosenOrDefaultAiConfigurationPresetId={
chosenOrDefaultAiConfigurationPresetId
}
setAiConfigurationPresetId={
setAiConfigurationPresetId
}
aiConfigurationPresetsWithAvailability={
aiConfigurationPresetsWithAvailability
}
aiRequestMode={newAiRequestMode}
/>
<RaisedButton
color="primary"
icon={<Send />}
@@ -588,7 +527,6 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
onStartNewAiRequest({
mode: newAiRequestMode,
userRequest: userRequestTextPerAiRequestId[''],
aiConfigurationPresetId: chosenOrDefaultAiConfigurationPresetId,
});
}}
/>

View File

@@ -2,16 +2,10 @@
import * as React from 'react';
import {
getAiRequest,
fetchAiSettings,
type AiRequest,
type AiSettings,
} from '../Utils/GDevelopServices/Generation';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { type EditorFunctionCallResult } from '../EditorFunctions/EditorFunctionCallRunner';
import Window from '../Utils/Window';
import { AI_SETTINGS_FETCH_TIMEOUT } from '../Utils/GlobalFetchTimeouts';
import { useAsyncLazyMemo } from '../Utils/UseLazyMemo';
import { retryIfFailed } from '../Utils/RetryIfFailed';
type EditorFunctionCallResultsStorage = {|
getEditorFunctionCallResults: (
@@ -192,7 +186,6 @@ export const useAiRequestsStorage = (): AiRequestStorage => {
type AiRequestContextState = {|
aiRequestStorage: AiRequestStorage,
editorFunctionCallResultsStorage: EditorFunctionCallResultsStorage,
getAiSettings: () => AiSettings | null,
|};
export const AiRequestContext = React.createContext<AiRequestContextState>({
@@ -210,7 +203,6 @@ export const AiRequestContext = React.createContext<AiRequestContextState>({
addEditorFunctionCallResults: () => {},
clearEditorFunctionCallResults: () => {},
},
getAiSettings: () => null,
});
type AiRequestProviderProps = {|
@@ -221,44 +213,12 @@ export const AiRequestProvider = ({ children }: AiRequestProviderProps) => {
const editorFunctionCallResultsStorage = useEditorFunctionCallResultsStorage();
const aiRequestStorage = useAiRequestsStorage();
const environment = Window.isDev() ? 'staging' : 'live';
const getAiSettings = useAsyncLazyMemo(
React.useCallback(
async (): Promise<AiSettings | null> => {
try {
const aiSettings = await retryIfFailed({ times: 2 }, () =>
fetchAiSettings({
environment,
})
);
return aiSettings;
} catch (error) {
console.error('Error while fetching AI settings:', error);
return null;
}
},
[environment]
)
);
React.useEffect(
() => {
const timeoutId = setTimeout(() => {
getAiSettings();
}, AI_SETTINGS_FETCH_TIMEOUT);
return () => clearTimeout(timeoutId);
},
[getAiSettings]
);
const state = React.useMemo(
() => ({
aiRequestStorage,
editorFunctionCallResultsStorage,
getAiSettings,
}),
[aiRequestStorage, editorFunctionCallResultsStorage, getAiSettings]
[aiRequestStorage, editorFunctionCallResultsStorage]
);
return (

View File

@@ -5,7 +5,6 @@ import { I18n } from '@lingui/react';
import {
type RenderEditorContainerPropsWithRef,
type SceneEventsOutsideEditorChanges,
type InstancesOutsideEditorChanges,
} from '../MainFrame/EditorContainers/BaseEditor';
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import Paper from '../UI/Paper';
@@ -50,12 +49,10 @@ import {
sendAiRequestMessageSent,
sendAiRequestStarted,
} from '../Utils/Analytics/EventSender';
import { useCreateAiProjectDialog } from './UseCreateAiProjectDialog';
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
import { listAllExamples } from '../Utils/GDevelopServices/Example';
import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider';
import { prepareAiUserContent } from './PrepareAiUserContent';
import { AiRequestContext } from './AiRequestContext';
import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration';
const gd: libGDevelop = global.gd;
@@ -69,7 +66,6 @@ const useProcessFunctionCalls = ({
getEditorFunctionCallResults,
addEditorFunctionCallResults,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
onExtensionInstalled,
}: {|
i18n: I18nType,
@@ -88,9 +84,6 @@ const useProcessFunctionCalls = ({
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
onInstancesModifiedOutsideEditor: (
changes: InstancesOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
|}) => {
const { ensureExtensionInstalled } = useEnsureExtensionInstalled({
@@ -165,7 +158,6 @@ const useProcessFunctionCalls = ({
});
},
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
ensureExtensionInstalled,
searchAndInstallAsset,
});
@@ -186,7 +178,6 @@ const useProcessFunctionCalls = ({
searchAndInstallAsset,
generateEvents,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
triggerSendEditorFunctionCallResults,
editorCallbacks,
]
@@ -200,7 +191,7 @@ const useProcessFunctionCalls = ({
editorFunctionCallResults: getEditorFunctionCallResults(
selectedAiRequest.id
),
}).filter(functionCall => functionCall.name !== 'initialize_project')
})
: [],
[selectedAiRequest, getEditorFunctionCallResults]
);
@@ -335,6 +326,7 @@ type Props = {|
storageProvider: ?StorageProvider,
setToolbar: (?React.Node) => void,
i18n: I18nType,
onCreateEmptyProject: (newProjectSetup: NewProjectSetup) => Promise<void>,
onCreateProjectFromExample: (
exampleShortHeader: ExampleShortHeader,
newProjectSetup: NewProjectSetup,
@@ -356,9 +348,6 @@ type Props = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
onInstancesModifiedOutsideEditor: (
changes: InstancesOutsideEditorChanges
) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
initialMode: 'chat' | 'agent' | null,
initialAiRequestId: string | null,
@@ -382,9 +371,6 @@ export type AskAiEditorInterface = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
onInstancesModifiedOutsideEditor: (
changes: InstancesOutsideEditorChanges
) => void,
startOrOpenChat: ({|
mode: 'chat' | 'agent',
aiRequestId: string | null,
@@ -394,7 +380,6 @@ export type AskAiEditorInterface = {|
export type NewAiRequestOptions = {|
mode: 'chat' | 'agent',
userRequest: string,
aiConfigurationPresetId: string,
|};
const noop = () => {};
@@ -410,10 +395,10 @@ export const AskAiEditor = React.memo<Props>(
fileMetadata,
storageProvider,
i18n,
onCreateEmptyProject,
onCreateProjectFromExample,
onOpenLayout,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
onExtensionInstalled,
initialMode,
initialAiRequestId,
@@ -485,7 +470,6 @@ export const AskAiEditor = React.memo<Props>(
const {
aiRequestStorage,
editorFunctionCallResultsStorage,
getAiSettings,
} = React.useContext(AiRequestContext);
const {
getEditorFunctionCallResults,
@@ -501,7 +485,10 @@ export const AskAiEditor = React.memo<Props>(
setLastSendError,
} = aiRequestStorage;
const {
createAiProject,
renderCreateAiProjectDialog,
} = useCreateAiProjectDialog();
const updateToolbar = React.useCallback(
() => {
@@ -528,7 +515,6 @@ export const AskAiEditor = React.memo<Props>(
onSceneObjectEdited: noop,
onSceneObjectsDeleted: noop,
onSceneEventsModifiedOutsideEditor: noop,
onInstancesModifiedOutsideEditor: noop,
startOrOpenChat: onStartOrOpenChat,
}));
@@ -586,14 +572,30 @@ export const AskAiEditor = React.memo<Props>(
}
// Read the options and reset them (to avoid launching the same request twice).
const {
mode,
userRequest,
aiConfigurationPresetId,
} = newAiRequestOptions;
const { mode, userRequest } = newAiRequestOptions;
startNewAiRequest(null);
// Do not create a project automatically anymore.
// If no project is opened, create a new empty one if the request is for
// the AI agent.
if (mode === 'agent' && !project) {
try {
console.info(
'No project opened, opening the dialog to create a new project.'
);
const result = await createAiProject();
if (result === 'canceled') {
return;
}
console.info('New project created - starting AI request.');
startNewAiRequest({
mode,
userRequest,
});
} catch (error) {
console.error('Error creating a new empty project:', error);
}
return;
}
// Ensure the user has enough credits to pay for the request, or ask them
// to buy some more.
@@ -645,10 +647,6 @@ export const AskAiEditor = React.memo<Props>(
fileMetadata,
storageProviderName,
mode,
toolsVersion: 'v2',
aiConfiguration: {
presetId: aiConfigurationPresetId,
},
});
console.info('Successfully created a new AI request:', aiRequest);
@@ -895,107 +893,6 @@ export const AskAiEditor = React.memo<Props>(
[onSendMessage]
);
const processInitializeProjectFunctionCalls = React.useCallback(
async (
functionCalls: Array<AiRequestMessageAssistantFunctionCall>
) => {
if (!selectedAiRequest) return;
// Mark all provided calls as working.
addEditorFunctionCallResults(
selectedAiRequest.id,
functionCalls.map(functionCall => ({
status: 'working',
call_id: functionCall.call_id,
}))
);
const results: Array<EditorFunctionCallResult> = [];
for (const functionCall of functionCalls) {
let args: any = null;
try {
args = JSON.parse(functionCall.arguments);
} catch (error) {
results.push({
status: 'finished',
call_id: functionCall.call_id,
success: false,
output: { message: 'Invalid arguments (not a valid JSON string).' },
});
continue;
}
const name: ?string = args && args.name;
const slug: ?string = args && args.slug;
if (!name || !slug) {
results.push({
status: 'finished',
call_id: functionCall.call_id,
success: false,
output: { message: 'Missing required arguments: name and slug.' },
});
continue;
}
try {
const fetchedAllExamples = await listAllExamples();
const exampleShortHeader: ?ExampleShortHeader = fetchedAllExamples.exampleShortHeaders.find(
exampleShortHeader => exampleShortHeader.slug === slug
);
if (!exampleShortHeader) {
results.push({
status: 'finished',
call_id: functionCall.call_id,
success: false,
output: { message: `Unable to find the example with slug "${slug}".` },
});
continue;
}
const newProjectSetup: NewProjectSetup = {
storageProvider: UrlStorageProvider,
saveAsLocation: null,
projectName: name,
dontOpenAnySceneOrProjectManager: true,
};
await onCreateProjectFromExample(
exampleShortHeader,
newProjectSetup,
i18n,
false
);
results.push({
status: 'finished',
call_id: functionCall.call_id,
success: true,
output: { message: `Initialized project "${name}" from example "${slug}".` },
});
} catch (error) {
console.error('Error initializing project from example:', error);
results.push({
status: 'finished',
call_id: functionCall.call_id,
success: false,
output: { message: error && error.message ? error.message : 'Unknown error while initializing project.' },
});
}
}
addEditorFunctionCallResults(selectedAiRequest.id, results);
await onSendEditorFunctionCallResults(null);
},
[
selectedAiRequest,
addEditorFunctionCallResults,
onCreateProjectFromExample,
i18n,
onSendEditorFunctionCallResults,
]
);
const onSendFeedback = React.useCallback(
async (
aiRequestId,
@@ -1036,68 +933,15 @@ export const AskAiEditor = React.memo<Props>(
getEditorFunctionCallResults,
addEditorFunctionCallResults,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
i18n,
onExtensionInstalled,
});
const onProcessFunctionCallsWithInit = React.useCallback(
async (
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
options: ?{| ignore?: boolean |}
) => {
const initializeCalls = functionCalls.filter(
functionCall => functionCall.name === 'initialize_project'
);
const otherCalls = functionCalls.filter(
functionCall => functionCall.name !== 'initialize_project'
);
if (initializeCalls.length > 0) {
await processInitializeProjectFunctionCalls(initializeCalls);
}
if (otherCalls.length > 0) {
await onProcessFunctionCalls(otherCalls, options);
}
},
[processInitializeProjectFunctionCalls, onProcessFunctionCalls]
);
// Auto-process initialize_project calls even without an opened project.
React.useEffect(
() => {
(async () => {
if (!selectedAiRequest) return;
const functionCallsToProcess = getFunctionCallsToProcess({
aiRequest: selectedAiRequest,
editorFunctionCallResults: getEditorFunctionCallResults(
selectedAiRequest.id
),
});
const initializeCalls = functionCallsToProcess.filter(
functionCall => functionCall.name === 'initialize_project'
);
if (initializeCalls.length === 0) return;
console.info('Processing initialize_project AI function calls...');
await processInitializeProjectFunctionCalls(initializeCalls);
})();
},
[
selectedAiRequest,
getEditorFunctionCallResults,
processInitializeProjectFunctionCalls,
]
);
return (
<>
<Paper square background="dark" style={styles.paper}>
<div style={styles.chatContainer}>
<AiRequestChat
aiConfigurationPresetsWithAvailability={getAiConfigurationPresetsWithAvailability(
{ limits, getAiSettings }
)}
project={project || null}
ref={aiRequestChatRef}
aiRequest={selectedAiRequest}
@@ -1113,7 +957,7 @@ export const AskAiEditor = React.memo<Props>(
? 'upgrade'
: 'none'
}
onProcessFunctionCalls={onProcessFunctionCallsWithInit}
onProcessFunctionCalls={onProcessFunctionCalls}
editorFunctionCallResults={
(selectedAiRequest &&
getEditorFunctionCallResults(selectedAiRequest.id)) ||
@@ -1142,6 +986,10 @@ export const AskAiEditor = React.memo<Props>(
/>
</div>
</Paper>
{renderCreateAiProjectDialog({
onCreateEmptyProject,
onCreateProjectFromExample,
})}
<AskAiHistory
open={isHistoryOpen}
onClose={onCloseHistory}
@@ -1176,14 +1024,12 @@ export const renderAskAiEditorContainer = (
storageProvider={props.storageProvider}
setToolbar={props.setToolbar}
isActive={props.isActive}
onCreateEmptyProject={props.onCreateEmptyProject}
onCreateProjectFromExample={props.onCreateProjectFromExample}
onOpenLayout={props.onOpenLayout}
onSceneEventsModifiedOutsideEditor={
props.onSceneEventsModifiedOutsideEditor
}
onInstancesModifiedOutsideEditor={
props.onInstancesModifiedOutsideEditor
}
onExtensionInstalled={props.onExtensionInstalled}
initialMode={
(props.extraEditorProps && props.extraEditorProps.mode) || null

View File

@@ -65,7 +65,7 @@ export const AnnouncementsFeed = ({
const classesForClickableContainer = useStylesForClickableContainer();
if (error && !hideLoader) {
if (error) {
return (
<PlaceholderError onRetry={fetchAnnouncementsAndPromotions}>
<Trans>

View File

@@ -155,7 +155,6 @@ export const getBundleTiles = ({
receivedBundles,
openedShopCategory,
hasAssetFiltersApplied,
onlyShowAssets,
}: {|
allBundleListingDatas: ?Array<BundleListingData>,
displayedBundleListingDatas: ?Array<BundleListingData>,
@@ -163,14 +162,12 @@ export const getBundleTiles = ({
receivedBundles: ?Array<any>,
openedShopCategory?: ?string,
hasAssetFiltersApplied?: boolean,
onlyShowAssets?: boolean,
|}): Array<React.Node> => {
if (
!allBundleListingDatas ||
!displayedBundleListingDatas ||
!onBundleSelection ||
hasAssetFiltersApplied ||
onlyShowAssets
hasAssetFiltersApplied
)
return [];

View File

@@ -580,7 +580,6 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
onBundleSelection,
receivedBundles,
hasAssetFiltersApplied,
onlyShowAssets,
}),
[
allBundleListingDatas,
@@ -588,7 +587,6 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
onBundleSelection,
receivedBundles,
hasAssetFiltersApplied,
onlyShowAssets,
]
);

View File

@@ -33,7 +33,6 @@ type Props = {|
id?: string,
objectType: string,
objectBehaviorsTypes: Array<string>,
isChildObject: boolean,
behaviorShortHeader: BehaviorShortHeader,
matches: ?Array<SearchMatch>,
onChoose: () => void,
@@ -46,7 +45,6 @@ export const BehaviorListItem = ({
id,
objectType,
objectBehaviorsTypes,
isChildObject,
behaviorShortHeader,
matches,
onChoose,
@@ -55,28 +53,20 @@ export const BehaviorListItem = ({
platform,
}: Props) => {
const alreadyAdded = objectBehaviorsTypes.includes(behaviorShortHeader.type);
const behaviorMetadata = gd.MetadataProvider.getBehaviorMetadata(
platform,
behaviorShortHeader.type
);
// An empty object type means the base object, i.e: any object.
const isObjectCompatible =
// An empty object type means the base object, i.e: any object.
(!behaviorShortHeader.objectType ||
objectType === behaviorShortHeader.objectType) &&
(!isChildObject || behaviorMetadata.isRelevantForChildObjects()) &&
behaviorShortHeader.allRequiredBehaviorTypes.every(requiredBehaviorType => {
const behaviorMetadata = gd.MetadataProvider.getBehaviorMetadata(
platform,
requiredBehaviorType
);
return (
(!isChildObject || behaviorMetadata.isRelevantForChildObjects()) &&
(!behaviorMetadata.isHidden() ||
objectBehaviorsTypes.includes(requiredBehaviorType))
!behaviorMetadata.isHidden() ||
objectBehaviorsTypes.includes(requiredBehaviorType)
);
});
const isEngineCompatible = isCompatibleWithGDevelopVersion(
getIDEVersion(),
behaviorShortHeader.gdevelopVersion

View File

@@ -62,7 +62,6 @@ type Props = {|
project: gdProject,
objectType: string,
objectBehaviorsTypes: Array<string>,
isChildObject: boolean,
installedBehaviorMetadataList: Array<BehaviorShortHeader>,
deprecatedBehaviorMetadataList: Array<BehaviorShortHeader>,
onInstall: (behaviorShortHeader: BehaviorShortHeader) => Promise<boolean>,
@@ -77,7 +76,6 @@ export const BehaviorStore = ({
project,
objectType,
objectBehaviorsTypes,
isChildObject,
installedBehaviorMetadataList,
deprecatedBehaviorMetadataList,
onInstall,
@@ -318,7 +316,6 @@ export const BehaviorStore = ({
key={behaviorShortHeader.type}
objectType={objectType}
objectBehaviorsTypes={objectBehaviorsTypes}
isChildObject={isChildObject}
onHeightComputed={onHeightComputed}
behaviorShortHeader={behaviorShortHeader}
matches={getExtensionsMatches(behaviorShortHeader)}

View File

@@ -1,53 +1,80 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { Column, Line, Spacer } from '../../UI/Grid';
import BundlePageHeader from './BundlePageHeader';
import { BundleStoreContext } from './BundleStoreContext';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import {
type BundleListingData,
type PrivateAssetPackListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
} from '../../Utils/GDevelopServices/Shop';
import {
getBundle,
type Bundle,
type Course,
} from '../../Utils/GDevelopServices/Asset';
import {
type PrivateAssetPackListingData,
type BundleListingData,
type PrivateGameTemplateListingData,
type CourseListingData,
} from '../../Utils/GDevelopServices/Shop';
import { type SubscriptionPlanWithPricingSystems } from '../../Utils/GDevelopServices/Usage';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import Text from '../../UI/Text';
import { Trans } from '@lingui/macro';
import AlertMessage from '../../UI/AlertMessage';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import FlatButton from '../../UI/FlatButton';
import {
getProductsIncludedInBundle,
getProductsIncludedInBundleTiles,
} from '../ProductPageHelper';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../AssetStoreContext';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { GridList, GridListTile } from '@material-ui/core';
ResponsiveLineStackLayout,
LineStackLayout,
ColumnStackLayout,
} from '../../UI/Layout';
import { Column, LargeSpacer, Line, Spacer } from '../../UI/Grid';
import {
getUserPublicProfile,
type UserPublicProfile,
} from '../../Utils/GDevelopServices/User';
import Link from '../../UI/Link';
import ResponsiveMediaGallery from '../../UI/ResponsiveMediaGallery';
import {
useResponsiveWindowSize,
type WindowSizeType,
} from '../../UI/Responsive/ResponsiveWindowMeasurer';
import Text from '../../UI/Text';
import CourseStoreContext from '../../Course/CourseStoreContext';
import { planIdSortingFunction } from '../../Profile/Subscription/PlanCard';
import SubscriptionPlanPricingSummary from '../../Profile/Subscription/PromotionSubscriptionDialog/SubscriptionPlanPricingSummary';
import { ResponsiveLineStackLayout } from '../../UI/Layout';
import SubscriptionPlanTableSummary from '../../Profile/Subscription/PromotionSubscriptionDialog/SubscriptionPlanTableSummary';
import { sendBundleBuyClicked } from '../../Utils/Analytics/EventSender';
import { MarkdownText } from '../../UI/MarkdownText';
import ScrollView from '../../UI/ScrollView';
import { shouldUseAppStoreProduct } from '../../Utils/AppStorePurchases';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import Avatar from '@material-ui/core/Avatar';
import GridList from '@material-ui/core/GridList';
import { BundleStoreContext } from './BundleStoreContext';
import {
getBundlesContainingProductTiles,
getOtherProductsFromSameAuthorTiles,
getProductMediaItems,
getProductsIncludedInBundle,
getProductsIncludedInBundleTiles,
getUserProductPurchaseUsageType,
PurchaseProductButtons,
} from '../ProductPageHelper';
import SecureCheckout from '../SecureCheckout/SecureCheckout';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import BundlePurchaseDialog from './BundlePurchaseDialog';
import PublicProfileContext from '../../Profile/PublicProfileContext';
import { LARGE_WIDGET_SIZE } from '../../MainFrame/EditorContainers/HomePage/CardWidget';
import SectionContainer, {
SectionRow,
} from '../../MainFrame/EditorContainers/HomePage/SectionContainer';
import { type CourseCompletion } from '../../MainFrame/EditorContainers/HomePage/UseCourses';
import CourseCard from '../../MainFrame/EditorContainers/HomePage/LearnSection/CourseCard';
import { PrivateGameTemplateStoreContext } from '../PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { AssetStoreContext } from '../AssetStoreContext';
import CourseStoreContext from '../../Course/CourseStoreContext';
import { getCreditsAmountFromId } from '../CreditsPackages/CreditsPackageStoreContext';
import Coin from '../../Credits/Icons/Coin';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../Profile/Subscription/PlanCard';
import RedemptionCodesDialog from '../../RedemptionCode/RedemptionCodesDialog';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
const getColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
const cellSpacing = 10;
const getTemplateColumns = (
windowSize: WindowSizeType,
isLandscape: boolean
) => {
switch (windowSize) {
case 'small':
return isLandscape ? 4 : 2;
@@ -61,50 +88,80 @@ const getColumns = (windowSize: WindowSizeType, isLandscape: boolean) => {
return 3;
}
};
const cellSpacing = 10;
const MAX_COLUMNS = getColumns('xlarge', true);
const MAX_COLUMNS = getTemplateColumns('xlarge', true);
const MAX_SECTION_WIDTH = (LARGE_WIDGET_SIZE + 2 * 5) * MAX_COLUMNS; // widget size + 5 padding per side
const styles = {
disabledText: { opacity: 0.6 },
scrollview: { overflowX: 'hidden' },
grid: {
// Avoid tiles taking too much space on large screens.
maxWidth: MAX_SECTION_WIDTH,
overflow: 'hidden',
width: `calc(100% + ${cellSpacing}px)`, // This is needed to compensate for the `margin: -5px` added by MUI related to spacing.
},
leftColumnContainer: {
flex: 1,
minWidth: 0, // This is needed for the container to take the right size.
},
rightColumnContainer: {
flex: 2,
},
leftColumnContainerMobile: {
flex: 1,
minWidth: 0, // This is needed for the container to take the right size.
},
rightColumnContainerMobile: {
flex: 1,
},
avatar: {
width: 20,
height: 20,
},
ownedTag: {
padding: '4px 8px',
borderRadius: 4,
color: 'black',
},
playIcon: {
width: 20,
height: 20,
},
coinIcon: {
width: 13,
height: 13,
position: 'relative',
top: -1,
},
};
type Props = {|
bundleListingData: BundleListingData,
onBack?: () => void | Promise<void>,
getSubscriptionPlansWithPricingSystems: () => Array<SubscriptionPlanWithPricingSystems> | null,
bundleListingDatasFromSameCreator?: ?Array<BundleListingData>,
receivedCourses: ?Array<Course>,
onBundleOpen: BundleListingData => void,
onGameTemplateOpen: PrivateGameTemplateListingData => void,
onAssetPackOpen: (
privateAssetPackListingData: PrivateAssetPackListingData
privateAssetPackListingData: PrivateAssetPackListingData,
options?: {|
forceProductPage?: boolean,
|}
) => void,
onCourseOpen: CourseListingData => void,
courses: ?Array<Course>,
receivedCourses: ?Array<Course>,
getCourseCompletion: (courseId: string) => CourseCompletion | null,
noPadding?: boolean,
simulateAppStoreProduct?: boolean,
|};
const BundleInformationPage = ({
bundleListingData,
onBack,
getSubscriptionPlansWithPricingSystems,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
courses,
bundleListingDatasFromSameCreator,
receivedCourses,
getCourseCompletion,
noPadding,
onBundleOpen,
onGameTemplateOpen,
onAssetPackOpen,
onCourseOpen,
simulateAppStoreProduct,
}: Props) => {
const { windowSize, isLandscape, isMobile } = useResponsiveWindowSize();
const { bundleListingDatas } = React.useContext(BundleStoreContext); // If archived, should use the one passed.
const { id, name, sellerId } = bundleListingData;
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
@@ -112,125 +169,181 @@ const BundleInformationPage = ({
const { listedCourses } = React.useContext(CourseStoreContext);
const {
receivedBundles,
bundlePurchases,
receivedGameTemplates,
receivedAssetPacks,
} = React.useContext(AuthenticatedUserContext);
const [bundle, setBundle] = React.useState<?Bundle>(null);
const [
purchasingBundleListingData,
setPurchasingBundleListingData,
] = React.useState<?BundleListingData>(null);
const [isFetching, setIsFetching] = React.useState<boolean>(false);
const { openUserPublicProfile } = React.useContext(PublicProfileContext);
const [
sellerPublicProfile,
setSellerPublicProfile,
] = React.useState<?UserPublicProfile>(null);
const [errorText, setErrorText] = React.useState<?React.Node>(null);
const {
palette: { type: paletteType },
} = React.useContext(GDevelopThemeContext);
windowSize,
isLandscape,
isMediumScreen,
isMobile,
} = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const [
isRedemptionCodesDialogOpen,
setIsRedemptionCodesDialogOpen,
] = React.useState<boolean>(false);
const courseAndTheirListingDataIncludedInBundle = React.useMemo(
(): Array<{|
course: Course,
courseListingData: CourseListingData,
|}> | null => {
if (!bundle || !bundleListingData || !courses) return null;
const productListingDatasInBundle = getProductsIncludedInBundle({
productListingData: bundleListingData,
productListingDatas: [...(listedCourses || [])],
});
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
if (!productListingDatasInBundle) return null;
// $FlowIgnore - Flow doesn't understand that we have filtered the products to only include courses.
const courseListingDatasInBundle: CourseListingData[] = productListingDatasInBundle.filter(
productListingData => productListingData.productType === 'COURSE'
const userBundlePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: bundleListingData ? bundleListingData.id : null,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
}),
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const isOwningAnotherVariant = React.useMemo(
() => {
if (!bundle || isAlreadyReceived || !receivedBundles) return false;
// Another bundle older version of that bundle can be owned.
// We look at the tag to determine if the bundle is the same.
return !!receivedBundles.find(
receivedBundle => receivedBundle.tag === bundle.tag
);
return (courseListingDatasInBundle || [])
.map(courseListingData => {
const course = courses.find(
course => course.id === courseListingData.id
);
if (!course) return null;
return {
course,
courseListingData,
};
})
.filter(Boolean);
},
[bundle, bundleListingData, listedCourses, courses]
[bundle, isAlreadyReceived, receivedBundles]
);
const productsExceptCoursesIncludedInBundleTiles = React.useMemo(
const additionalProductThumbnailsIncludedInBundle: string[] = React.useMemo(
() => {
const productsIncludedInBundle = getProductsIncludedInBundle({
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
],
productListingData: bundleListingData,
});
if (!productsIncludedInBundle) return [];
const additionalThumbnails = productsIncludedInBundle
.map(product => (product.thumbnailUrls || []).slice(0, 2))
.reduce((acc, thumbnails) => acc.concat(thumbnails), []);
return additionalThumbnails;
},
[
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
bundleListingData,
]
);
const productsIncludedInBundleTiles = React.useMemo(
() =>
bundle && bundleListingData
? getProductsIncludedInBundleTiles({
product: bundle,
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
],
productListingData: bundleListingData,
receivedProducts: [
...(receivedBundles || []),
...(receivedGameTemplates || []),
...(receivedAssetPacks || []),
],
onPrivateAssetPackOpen: onAssetPackOpen,
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
discountedPrice: true,
})
: null,
getProductsIncludedInBundleTiles({
product: bundle,
productListingDatas: [
...(bundleListingDatas || []),
...(privateGameTemplateListingDatas || []),
...(privateAssetPackListingDatas || []),
...(listedCourses || []),
],
productListingData: bundleListingData,
receivedProducts: [
...(receivedBundles || []),
...(receivedGameTemplates || []),
...(receivedAssetPacks || []),
...(receivedCourses || []),
],
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
}),
[
bundle,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
receivedBundles,
receivedGameTemplates,
receivedAssetPacks,
bundleListingData,
receivedCourses,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
onCourseOpen,
bundleListingData,
]
);
const subscriptionPlansWithPricingSystems = getSubscriptionPlansWithPricingSystems();
const bundlesContainingBundleTiles = React.useMemo(
() =>
getBundlesContainingProductTiles({
product: bundle,
productListingData: bundleListingData,
productListingDatas: bundleListingDatas,
receivedProducts: receivedBundles,
onPrivateAssetPackOpen: product =>
onAssetPackOpen(product, { forceProductPage: true }),
onPrivateGameTemplateOpen: onGameTemplateOpen,
onBundleOpen,
}),
[
bundle,
bundleListingData,
bundleListingDatas,
receivedBundles,
onAssetPackOpen,
onGameTemplateOpen,
onBundleOpen,
]
);
const highestSubscriptionPlanIncludedInBundle = React.useMemo(
() => {
if (!bundleListingData) return null;
const sortedIncludedRedemptionCodes = (
bundleListingData.includedRedemptionCodes || []
).sort((a, b) =>
planIdSortingFunction(
a.givenSubscriptionPlanId,
b.givenSubscriptionPlanId
)
);
if (!sortedIncludedRedemptionCodes.length) return null;
const planId =
sortedIncludedRedemptionCodes[sortedIncludedRedemptionCodes.length - 1]
.givenSubscriptionPlanId;
return subscriptionPlansWithPricingSystems
? subscriptionPlansWithPricingSystems.find(
subscriptionPlanWithPricingSystems =>
subscriptionPlanWithPricingSystems.id === planId
)
: null;
},
[bundleListingData, subscriptionPlansWithPricingSystems]
const otherBundlesFromTheSameAuthorTiles = React.useMemo(
() =>
getOtherProductsFromSameAuthorTiles({
otherProductListingDatasFromSameCreator: bundleListingDatasFromSameCreator,
currentProductListingData: bundleListingData,
receivedProducts: receivedBundles,
onProductOpen: onBundleOpen,
}),
[
bundleListingDatasFromSameCreator,
bundleListingData,
receivedBundles,
onBundleOpen,
]
);
React.useEffect(
() => {
(async () => {
setIsFetching(true);
try {
const bundle = await getBundle(bundleListingData.id);
const [bundle, profile] = await Promise.all([
getBundle(id),
getUserPublicProfile(sellerId),
]);
setBundle(bundle);
setSellerPublicProfile(profile);
} catch (error) {
const extractedStatusAndCode = extractGDevelopApiErrorStatusAndCode(
error
@@ -246,149 +359,328 @@ const BundleInformationPage = ({
<Trans>An error occurred, please try again later.</Trans>
);
}
} finally {
setIsFetching(false);
}
})();
},
[bundleListingData.id]
[id, sellerId]
);
const customSectionPaperStyle = {
// $FlowIgnore
...(noPadding
? {
padding: 0,
}
: {}),
...(bundleListingData.visibleUntil && !noPadding
? {
backgroundAttachment: 'local',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'top',
backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto',
backgroundImage: `${
paletteType === 'dark'
? 'linear-gradient(180deg, #6B1A18 0px, #2D2331 5%, #1d1d2600 10%)'
: 'linear-gradient(180deg, #F03F18 0px, #f5f5f700 10%)'
}`,
}
: {}),
};
const onClickBuy = React.useCallback(
async () => {
if (!bundle || isOwningAnotherVariant) return;
if (isAlreadyReceived) {
onBundleOpen(bundleListingData);
return;
}
if (errorText) {
return (
<SectionContainer
flexBody
backAction={onBack}
customPaperStyle={customSectionPaperStyle}
>
<SectionRow expand>
<Line alignItems="center" justifyContent="center" expand>
<AlertMessage kind="error">{errorText}</AlertMessage>
</Line>
</SectionRow>
</SectionContainer>
);
}
try {
const price = bundleListingData.prices.find(
price => price.usageType === 'default'
);
if (!bundleListingData || !bundle) {
return (
<SectionContainer flexBody customPaperStyle={customSectionPaperStyle}>
<SectionRow expand>
<PlaceholderLoader />
</SectionRow>
</SectionContainer>
);
}
sendBundleBuyClicked({
bundleId: bundle.id,
bundleName: bundle.name,
bundleTag: bundle.tag,
currency: price ? price.currency : undefined,
usageType: 'default',
});
setPurchasingBundleListingData(bundleListingData);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[
bundle,
bundleListingData,
isAlreadyReceived,
isOwningAnotherVariant,
onBundleOpen,
]
);
const mediaItems = React.useMemo(
() =>
getProductMediaItems({
product: bundle,
productListingData: bundleListingData,
shouldSimulateAppStoreProduct: simulateAppStoreProduct,
additionalThumbnails: additionalProductThumbnailsIncludedInBundle,
}),
[
bundle,
bundleListingData,
simulateAppStoreProduct,
additionalProductThumbnailsIncludedInBundle,
]
);
const includedCreditsAmount = React.useMemo(
() =>
(bundleListingData.includedListableProducts || [])
.filter(product => product.productType === 'CREDIT_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
),
[bundleListingData]
);
const includedRedemptionCodes = React.useMemo(
() => bundleListingData.includedRedemptionCodes || [],
[bundleListingData]
);
return (
<I18n>
{({ i18n }) => (
<SectionContainer
backAction={onBack}
customPaperStyle={customSectionPaperStyle}
>
<BundlePageHeader
bundleListingData={bundleListingData}
bundle={bundle}
i18n={i18n}
/>
<Line noMargin>
<Text size="section-title">
<Trans>What's included:</Trans>
</Text>
</Line>
{courseAndTheirListingDataIncludedInBundle &&
courseAndTheirListingDataIncludedInBundle.length > 0 && (
<Line>
<GridList
cols={getColumns(windowSize, isLandscape)}
style={styles.grid}
cellHeight="auto"
spacing={cellSpacing}
>
{courseAndTheirListingDataIncludedInBundle.map(
({ course, courseListingData }) => {
const completion = getCourseCompletion(course.id);
return (
<GridListTile key={course.id}>
<CourseCard
course={course}
courseListingData={courseListingData}
completion={completion}
onClick={() => {
onCourseOpen(courseListingData);
}}
discountedPrice
/>
</GridListTile>
);
}
)}
</GridList>
</Line>
)}
{productsExceptCoursesIncludedInBundleTiles && (
<Line>
<GridList
cols={getColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{productsExceptCoursesIncludedInBundleTiles}
</GridList>
<>
{errorText ? (
<Line alignItems="center" justifyContent="center" expand>
<AlertMessage kind="error">{errorText}</AlertMessage>
</Line>
)}
{highestSubscriptionPlanIncludedInBundle && (
<ResponsiveLineStackLayout expand noColumnMargin>
<Column noMargin justifyContent="center">
<Line expand>
<SubscriptionPlanPricingSummary
subscriptionPlanWithPricingSystems={
highestSubscriptionPlanIncludedInBundle
) : isFetching ? (
<Column expand alignItems="center" justifyContent="center">
<PlaceholderLoader />
</Column>
) : bundle && sellerPublicProfile ? (
<Column noOverflowParent expand noMargin>
<ScrollView autoHideScrollbar style={styles.scrollview}>
<ResponsiveLineStackLayout
noColumnMargin
noMargin
// Force the columns to wrap on tablets and small screens.
forceMobileLayout={isMediumScreen}
// Prevent it to wrap when in landscape mode on small screens.
noResponsiveLandscape
useLargeSpacer
>
<div
style={
isMobile
? styles.leftColumnContainerMobile
: styles.leftColumnContainer
}
disabled={false}
onClickChoosePlan={async () => {}}
seatsCount={0}
setSeatsCount={() => {}}
period={'month'}
setPeriod={() => {}}
onlyShowDiscountedPrice
/>
</Line>
</Column>
<Spacer />
<Column noMargin>
<SubscriptionPlanTableSummary
subscriptionPlanWithPricingSystems={
highestSubscriptionPlanIncludedInBundle
}
hideActions
/>
</Column>
</ResponsiveLineStackLayout>
>
<ResponsiveMediaGallery
mediaItems={mediaItems}
altTextTemplate={`Bundle ${name} preview image {mediaIndex}`}
horizontalOuterMarginToEatOnMobile={8}
/>
</div>
<div
style={
isMobile
? styles.rightColumnContainerMobile
: styles.rightColumnContainer
}
>
<ColumnStackLayout noMargin>
<LineStackLayout
noMargin
alignItems="center"
justifyContent="space-between"
>
<Text noMargin size="title">
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text>
{isAlreadyReceived && (
<div
style={{
...styles.ownedTag,
backgroundColor:
gdevelopTheme.statusIndicator.success,
}}
>
<Text color="inherit" noMargin>
<Trans>OWNED</Trans>
</Text>
</div>
)}
</LineStackLayout>
<LineStackLayout noMargin alignItems="center">
<Avatar
src={sellerPublicProfile.iconUrl}
style={styles.avatar}
/>
<Text displayInlineAsSpan size="sub-title">
<Link
onClick={() =>
openUserPublicProfile({
userId: sellerPublicProfile.id,
callbacks: {
onAssetPackOpen,
onGameTemplateOpen,
},
})
}
href="#"
>
{sellerPublicProfile.username || ''}
</Link>
</Text>
</LineStackLayout>
<Spacer />
{isOwningAnotherVariant ? (
<AlertMessage kind="warning">
<Trans>
You own an older version of this bundle. Browse the
store to access it!
</Trans>
</AlertMessage>
) : !isAlreadyReceived ? (
<>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
{!errorText && (
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
/>
)}
</>
) : null}
<Text size="body2" displayInlineAsSpan>
<MarkdownText
source={selectMessageByLocale(
i18n,
bundle.longDescriptionByLocale
)}
allowParagraphs
/>
</Text>
{includedRedemptionCodes.length > 0 && (
<ColumnStackLayout noMargin>
{includedRedemptionCodes.map(
(includedRedemptionCode, index) => (
<LineStackLayout
noMargin
alignItems="center"
key={`${
includedRedemptionCode.givenSubscriptionPlanId
}-${index}`}
>
{getPlanIcon({
planId:
includedRedemptionCode.givenSubscriptionPlanId,
logoSize: 20,
})}
<Text>
<Trans>
{formatDurationOfRedemptionCode(
includedRedemptionCode.durationInDays
)}{' '}
of
{getPlanInferredNameFromId(
includedRedemptionCode.givenSubscriptionPlanId
)}
subscription included
</Trans>
</Text>
</LineStackLayout>
)
)}
{isAlreadyReceived && (
<Line noMargin>
<FlatButton
primary
label={<Trans>See my codes</Trans>}
onClick={() =>
setIsRedemptionCodesDialogOpen(true)
}
/>
</Line>
)}
</ColumnStackLayout>
)}
{includedCreditsAmount > 0 && (
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.coinIcon} />
<Text>
<Trans>
{includedCreditsAmount} credits included
</Trans>
</Text>
</LineStackLayout>
)}
</ColumnStackLayout>
</div>
</ResponsiveLineStackLayout>
{bundlesContainingBundleTiles &&
bundlesContainingBundleTiles.length ? (
<>
<ColumnStackLayout noMargin>
<LargeSpacer />
{bundlesContainingBundleTiles}
<LargeSpacer />
</ColumnStackLayout>
</>
) : null}
{productsIncludedInBundleTiles && (
<>
<Line>
<Text size="block-title">
<Trans>Included in this bundle</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{productsIncludedInBundleTiles}
</GridList>
</Line>
</>
)}
{otherBundlesFromTheSameAuthorTiles &&
otherBundlesFromTheSameAuthorTiles.length > 0 && (
<>
<Line>
<Text size="block-title">
<Trans>Similar bundles</Trans>
</Text>
</Line>
<Line>
<GridList
cols={getTemplateColumns(windowSize, isLandscape)}
cellHeight="auto"
spacing={cellSpacing}
style={styles.grid}
>
{otherBundlesFromTheSameAuthorTiles}
</GridList>
</Line>
</>
)}
</ScrollView>
</Column>
) : null}
{!!purchasingBundleListingData && (
<BundlePurchaseDialog
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
/>
)}
</SectionContainer>
{isRedemptionCodesDialogOpen && (
<RedemptionCodesDialog
onClose={() => setIsRedemptionCodesDialogOpen(false)}
/>
)}
</>
)}
</I18n>
);

View File

@@ -1,517 +0,0 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import { type Bundle } from '../../Utils/GDevelopServices/Asset';
import { type BundleListingData } from '../../Utils/GDevelopServices/Shop';
import Paper from '../../UI/Paper';
import Text from '../../UI/Text';
import { Column, Line } from '../../UI/Grid';
import {
ColumnStackLayout,
LineStackLayout,
ResponsiveLineStackLayout,
} from '../../UI/Layout';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import { selectMessageByLocale } from '../../Utils/i18n/MessageByLocale';
import { renderProductPrice } from '../../AssetStore/ProductPriceTag';
import {
getProductsIncludedInBundle,
getUserProductPurchaseUsageType,
PurchaseProductButtons,
} from '../../AssetStore/ProductPageHelper';
import { shouldUseAppStoreProduct } from '../../Utils/AppStorePurchases';
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
import { BundleStoreContext } from '../../AssetStore/Bundles/BundleStoreContext';
import { sendBundleBuyClicked } from '../../Utils/Analytics/EventSender';
import BundlePurchaseDialog from '../../AssetStore/Bundles/BundlePurchaseDialog';
import RedemptionCodesDialog from '../../RedemptionCode/RedemptionCodesDialog';
import {
getEstimatedSavingsFormatted,
renderEstimatedTotalPriceFormatted,
} from '../../AssetStore/Bundles/Utils';
import { PrivateGameTemplateStoreContext } from '../../AssetStore/PrivateGameTemplates/PrivateGameTemplateStoreContext';
import { CreditsPackageStoreContext } from '../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
import { AssetStoreContext } from '../../AssetStore/AssetStoreContext';
import CourseStoreContext from '../../Course/CourseStoreContext';
import SecureCheckout from '../../AssetStore/SecureCheckout/SecureCheckout';
import FlatButton from '../../UI/FlatButton';
import Chip from '../../UI/Chip';
import ProductLimitedTimeOffer from '../../AssetStore/ProductLimitedTimeOffer';
import Skeleton from '@material-ui/lab/Skeleton';
import { getSummaryLines } from './Utils';
import { SectionRow } from '../../MainFrame/EditorContainers/HomePage/SectionContainer';
const styles = {
title: { overflowWrap: 'anywhere', textWrap: 'wrap' },
image: { width: 300, aspectRatio: '16 / 9' },
imageContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
borderRadius: 8,
overflow: 'hidden',
},
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
discountChip: {
height: 24,
backgroundColor: '#F03F18',
color: 'white',
},
};
type Props = {|
bundleListingData: BundleListingData,
bundle: Bundle,
simulateAppStoreProduct?: boolean,
i18n: I18nType,
|};
const BundlePageHeader = ({
bundle,
bundleListingData,
simulateAppStoreProduct,
i18n,
}: Props) => {
const { privateGameTemplateListingDatas } = React.useContext(
PrivateGameTemplateStoreContext
);
const { creditsPackageListingDatas } = React.useContext(
CreditsPackageStoreContext
);
const { bundleListingDatas } = React.useContext(BundleStoreContext);
const { privateAssetPackListingDatas } = React.useContext(AssetStoreContext);
const { listedCourses } = React.useContext(CourseStoreContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const { receivedBundles, bundlePurchases } = authenticatedUser;
const [
purchasingBundleListingData,
setPurchasingBundleListingData,
] = React.useState<?BundleListingData>(null);
const { isMobile, isMediumScreen } = useResponsiveWindowSize();
const isMobileOrMediumScreen = isMobile || isMediumScreen;
const [
isRedemptionCodesDialogOpen,
setIsRedemptionCodesDialogOpen,
] = React.useState<boolean>(false);
const shouldUseOrSimulateAppStoreProduct =
shouldUseAppStoreProduct() || simulateAppStoreProduct;
const userBundlePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: bundleListingData ? bundleListingData.id : null,
receivedProducts: receivedBundles,
productPurchases: bundlePurchases,
allProductListingDatas: bundleListingDatas,
}),
[bundlePurchases, bundleListingData, bundleListingDatas, receivedBundles]
);
const isAlreadyReceived = !!userBundlePurchaseUsageType;
const productListingDatasIncludedInBundle = React.useMemo(
() =>
bundleListingData &&
bundleListingDatas &&
privateGameTemplateListingDatas &&
privateAssetPackListingDatas &&
listedCourses &&
creditsPackageListingDatas
? getProductsIncludedInBundle({
productListingDatas: [
...bundleListingDatas,
...privateGameTemplateListingDatas,
...privateAssetPackListingDatas,
...listedCourses,
...creditsPackageListingDatas,
],
productListingData: bundleListingData,
})
: null,
[
bundleListingData,
bundleListingDatas,
privateGameTemplateListingDatas,
privateAssetPackListingDatas,
listedCourses,
creditsPackageListingDatas,
]
);
const redemptionCodesIncludedInBundle = React.useMemo(
() =>
bundleListingData
? bundleListingData.includedRedemptionCodes || []
: null,
[bundleListingData]
);
const summaryLines = React.useMemo(
() => {
if (
!productListingDatasIncludedInBundle ||
!redemptionCodesIncludedInBundle ||
!bundleListingData
)
return isMobile ? (
<ColumnStackLayout noMargin>
<Column expand noMargin>
<Skeleton height={25} />
<Skeleton height={20} />
<Skeleton height={20} />
</Column>
<Column expand noMargin>
<Skeleton height={25} />
<Skeleton height={20} />
<Skeleton height={20} />
</Column>
</ColumnStackLayout>
) : (
<Column expand noMargin>
<Skeleton height={25} />
<Skeleton height={20} />
<Skeleton height={20} />
</Column>
);
if (isAlreadyReceived) {
return (
<Line noMargin>
<FlatButton
primary
label={<Trans>See my subscription codes</Trans>}
onClick={() => setIsRedemptionCodesDialogOpen(true)}
/>
</Line>
);
}
const summaryLines = getSummaryLines({
redemptionCodesIncludedInBundle,
bundleListingData,
productListingDatasIncludedInBundle,
});
if (isMobile) {
return summaryLines.mobileLines;
}
return summaryLines.desktopLines;
},
[
isAlreadyReceived,
redemptionCodesIncludedInBundle,
bundleListingData,
productListingDatasIncludedInBundle,
isMobile,
]
);
const estimatedTotalPriceFormatted = React.useMemo(
() =>
renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}),
[
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
]
);
const estimatedSavingsFormatted = React.useMemo(
() =>
getEstimatedSavingsFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}),
[
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
]
);
const productPrice = React.useMemo(
() =>
renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
plainText: true,
}),
[i18n, bundleListingData]
);
const onClickBuy = React.useCallback(
async () => {
if (!bundle) return;
if (isAlreadyReceived) {
return;
}
try {
const price = bundleListingData.prices.find(
price => price.usageType === 'default'
);
sendBundleBuyClicked({
bundleId: bundle.id,
bundleName: bundle.name,
bundleTag: bundle.tag,
currency: price ? price.currency : undefined,
usageType: 'default',
});
setPurchasingBundleListingData(bundleListingData);
} catch (e) {
console.warn('Unable to send event', e);
}
},
[bundle, bundleListingData, isAlreadyReceived]
);
return (
<I18n>
{({ i18n }) => (
<Column noOverflowParent noMargin>
{!isAlreadyReceived && !isMobileOrMediumScreen && (
<SectionRow>
<Paper background="medium" style={{ padding: 16 }}>
{!!bundleListingData && (
<LineStackLayout noMargin justifyContent="space-between">
{bundleListingData.visibleUntil ? (
<ProductLimitedTimeOffer
visibleUntil={bundleListingData.visibleUntil}
/>
) : estimatedSavingsFormatted ? (
<Column
noMargin
alignItems="flex-start"
justifyContent="center"
>
<Chip
label={<Trans>Bundle</Trans>}
style={styles.discountChip}
/>
<Text color="secondary">
<Trans>
Get{' '}
{estimatedSavingsFormatted.savingsPriceFormatted}{' '}
worth of value for less!
</Trans>
</Text>
</Column>
) : (
<Skeleton height={24} width={100} />
)}
{estimatedTotalPriceFormatted &&
estimatedSavingsFormatted &&
productPrice ? (
<LineStackLayout justifyContent="flex-end" noMargin>
<Column noMargin alignItems="flex-end">
<LineStackLayout>
<Text noMargin color="secondary" size="block-title">
<span style={styles.discountedPrice}>
{estimatedTotalPriceFormatted}
</span>
</Text>
{bundleListingData.visibleUntil && (
<Chip
label={
<Trans>
{
estimatedSavingsFormatted.savingsPercentageFormatted
}{' '}
OFF
</Trans>
}
style={styles.discountChip}
/>
)}
</LineStackLayout>
<Text noMargin size="block-title">
{productPrice}
</Text>
</Column>
<ColumnStackLayout noMargin alignItems="center">
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
customLabel={
<Trans>
Buy now and save{' '}
{
estimatedSavingsFormatted.savingsPriceFormatted
}
</Trans>
}
/>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
</ColumnStackLayout>
</LineStackLayout>
) : (
<Column noMargin justifyContent="flex-end">
<Skeleton height={24} width={100} />
<Skeleton height={24} width={100} />
</Column>
)}
</LineStackLayout>
)}
</Paper>
</SectionRow>
)}
{!isAlreadyReceived &&
isMobileOrMediumScreen &&
bundleListingData &&
bundleListingData.visibleUntil && (
<SectionRow>
<ProductLimitedTimeOffer
visibleUntil={bundleListingData.visibleUntil}
/>
</SectionRow>
)}
<SectionRow>
<ResponsiveLineStackLayout
noMargin
alignItems="center"
justifyContent="flex-start"
forceMobileLayout={isMediumScreen}
expand
>
<div style={styles.imageContainer}>
<img
src={bundle.previewImageUrls[0]}
style={styles.image}
alt=""
/>
</div>
<ColumnStackLayout
expand
justifyContent="flex-start"
noMargin={isMobile}
>
<Text size="title" noMargin style={styles.title}>
{selectMessageByLocale(i18n, bundle.nameByLocale)}
</Text>
<Line noMargin>
<Text noMargin>
{selectMessageByLocale(
i18n,
bundle.longDescriptionByLocale
)}
</Text>
</Line>
{summaryLines}
</ColumnStackLayout>
</ResponsiveLineStackLayout>
</SectionRow>
{!isAlreadyReceived && isMobileOrMediumScreen && bundleListingData && (
<SectionRow>
<Paper background="medium" style={{ padding: 16 }}>
{estimatedTotalPriceFormatted &&
estimatedSavingsFormatted &&
productPrice ? (
<ResponsiveLineStackLayout justifyContent="flex-end" noMargin>
<LineStackLayout
alignItems="center"
justifyContent="center"
>
{bundleListingData.visibleUntil && (
<Chip
label={
<Trans>
{
estimatedSavingsFormatted.savingsPercentageFormatted
}{' '}
OFF
</Trans>
}
style={styles.discountChip}
/>
)}
<Text noMargin color="secondary" size="block-title">
<span style={styles.discountedPrice}>
{estimatedTotalPriceFormatted}
</span>
</Text>
<Text noMargin size="block-title">
{productPrice}
</Text>
</LineStackLayout>
<ColumnStackLayout noMargin alignItems="center">
<PurchaseProductButtons
i18n={i18n}
productListingData={bundleListingData}
selectedUsageType="default"
onUsageTypeChange={() => {}}
simulateAppStoreProduct={simulateAppStoreProduct}
isAlreadyReceived={isAlreadyReceived}
onClickBuy={onClickBuy}
onClickBuyWithCredits={() => {}}
customLabel={
<Trans>
Buy now and save{' '}
{
(
getEstimatedSavingsFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}) || {}
).savingsPriceFormatted
}
</Trans>
}
/>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
</ColumnStackLayout>
</ResponsiveLineStackLayout>
) : (
<Column noMargin expand justifyContent="flex-end">
<Skeleton height={isMobile ? 60 : 40} />
<Skeleton height={isMobile ? 60 : 40} />
</Column>
)}
</Paper>
</SectionRow>
)}
{!!purchasingBundleListingData && (
<BundlePurchaseDialog
bundleListingData={purchasingBundleListingData}
usageType="default"
onClose={() => setPurchasingBundleListingData(null)}
/>
)}
{isRedemptionCodesDialogOpen && (
<RedemptionCodesDialog
onClose={() => setIsRedemptionCodesDialogOpen(false)}
/>
)}
</Column>
)}
</I18n>
);
};
export default BundlePageHeader;

View File

@@ -2,7 +2,6 @@
import * as React from 'react';
import { I18n } from '@lingui/react';
import { type I18n as I18nType } from '@lingui/core';
import { Trans } from '@lingui/macro';
import Divider from '@material-ui/core/Divider';
import {
@@ -49,12 +48,8 @@ import Hammer from '../../UI/CustomSvgIcons/Hammer';
import School from '../../UI/CustomSvgIcons/School';
import Coin from '../../Credits/Icons/Coin';
import Sparkle from '../../UI/CustomSvgIcons/Sparkle';
import {
getEstimatedSavingsFormatted,
renderEstimatedTotalPriceFormatted,
} from './Utils';
import { renderEstimatedTotalPriceFormatted } from './Utils';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
import ProductLimitedTimeOffer from '../ProductLimitedTimeOffer';
const highlightColor = '#6CF9F7';
@@ -67,7 +62,7 @@ const styles = {
display: 'flex',
flex: 1,
flexDirection: 'column',
gap: 16,
gap: 8,
justifyContent: 'space-between',
},
bundlePreviewContainer: {
@@ -259,11 +254,9 @@ const getColumnsFromWindowSize = (windowSize: WindowSizeType) => {
type Props = {|
onDisplayBundle: (bundleListingData: BundleListingData) => void,
i18n: I18nType,
category: string,
|};
const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
const BundlePreviewBanner = ({ onDisplayBundle }: Props) => {
const { isMobile, isLandscape, windowSize } = useResponsiveWindowSize();
const numberOfTilesToDisplay = getColumnsFromWindowSize(windowSize) - 1; // Reserve one tile for the bundle preview.
const { privateGameTemplateListingDatas } = React.useContext(
@@ -279,34 +272,28 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
const { bundlePurchases, receivedBundles } = authenticatedUser;
// For the moment, we either display:
// - the first bundle of that category if none are owned.
// - the first owned bundle of that category (as a listing data if still listed, or as an archived listing data otherwise)
// - the first bundle in the list if none are owned.
// - the first owned bundle (as a listing data if still listed, or as an archived listing data otherwise)
// TODO: improve that logic when we'll have more bundles.
const bundleListingData: BundleListingData | null = React.useMemo(
() => {
if (!bundleListingDatas || !receivedBundles) return null;
const bundleListingDataOfCategory = bundleListingDatas.filter(bundle =>
bundle.categories.includes(category)
)[0];
const receivedBundleOfCategory = receivedBundles.filter(bundle =>
bundle.categories.includes(category)
)[0];
if (!receivedBundleOfCategory) {
// Display the first bundle if none are found with that category.
return bundleListingDataOfCategory || bundleListingDatas[0];
if (receivedBundles.length === 0) {
return bundleListingDatas[0]; // Display the first bundle if none are owned.
}
const bundleListingDataMatchingOwnedBundle = bundleListingDatas.find(
bundleListingData =>
bundleListingData.id === receivedBundleOfCategory.id
const receivedBundle = receivedBundles[0];
const bundleListingData = bundleListingDatas.find(
bundleListingData => bundleListingData.id === receivedBundle.id
);
if (bundleListingData) {
return bundleListingData; // Display the first owned bundle that is still listed.
}
// If this bundle is not listed anymore, get an archived listing data for that bundle.
return (
bundleListingDataMatchingOwnedBundle ||
getArchivedBundleListingData({
bundle: receivedBundleOfCategory,
})
);
return getArchivedBundleListingData({
bundle: receivedBundle,
});
},
[bundleListingDatas, receivedBundles, category]
[bundleListingDatas, receivedBundles]
);
const userBundlePurchaseUsageType = React.useMemo(
@@ -355,51 +342,6 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
[bundleListingData]
);
const estimatedTotalPriceFormatted = React.useMemo(
() =>
renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}),
[
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
]
);
const estimatedSavingsFormatted = React.useMemo(
() =>
getEstimatedSavingsFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}),
[
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
]
);
const productPrice = React.useMemo(
() =>
bundleListingData
? renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
plainText: true,
})
: null,
[i18n, bundleListingData]
);
const courseTiles = React.useMemo(
() => {
if (isMobile && !isLandscape) {
@@ -491,6 +433,27 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
}}
>
<ColumnStackLayout noMargin>
<Line noMargin>
{bundleListingData ? (
<Chip
label={
isAlreadyReceived ? (
<Trans>Owned</Trans>
) : (
<Trans>Discount</Trans>
)
}
style={
isAlreadyReceived
? styles.ownedChip
: styles.discountChip
}
/>
) : (
<Skeleton variant="rect" height={20} />
)}
</Line>
<Spacer />
{bundleListingData ? (
<Text noMargin size="block-title">
{bundleListingData.nameByLocale
@@ -515,74 +478,20 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
) : (
<Skeleton height={30} />
)}
{isMobile && (
<BundlePreviewTile bundleListingData={bundleListingData} />
)}
</ColumnStackLayout>
{bundleListingData ? (
<ColumnStackLayout noMargin>
{estimatedSavingsFormatted &&
estimatedTotalPriceFormatted &&
productPrice !== null ? (
<LineStackLayout
alignItems="center"
justifyContent={
bundleListingData.visibleUntil && !isMobile
? 'flex-start'
: 'space-between'
}
noMargin
>
<LineStackLayout noMargin alignItems="center">
{!isAlreadyReceived && estimatedTotalPriceFormatted && (
<Text noMargin color="secondary" size="sub-title">
<span style={styles.discountedPrice}>
{estimatedTotalPriceFormatted}
</span>
</Text>
)}
{(isAlreadyReceived ||
!bundleListingData.visibleUntil) && (
<Chip
label={
isAlreadyReceived ? (
<Trans>Owned</Trans>
) : (
<Trans>
{
estimatedSavingsFormatted.savingsPercentageFormatted
}{' '}
OFF
</Trans>
)
}
style={
isAlreadyReceived
? styles.ownedChip
: styles.discountChip
}
/>
)}
</LineStackLayout>
{!isAlreadyReceived && (
<Text noMargin size="block-title">
{productPrice}
</Text>
)}
</LineStackLayout>
) : (
<Skeleton variant="rect" height={20} />
)}
{!isAlreadyReceived && bundleListingData.visibleUntil && (
<Line noMargin expand>
<Column noMargin expand>
<ProductLimitedTimeOffer
visibleUntil={bundleListingData.visibleUntil}
hideMinutesAndSeconds
alignCenter
/>
</Column>
</Line>
{!isAlreadyReceived && (
<Text noMargin color="secondary">
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
})}
</span>
</Text>
)}
<RaisedButton
primary
@@ -590,7 +499,15 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
isAlreadyReceived ? (
<Trans>Browse bundle</Trans>
) : (
<Trans>Discover this bundle</Trans>
<Trans>
Buy for{' '}
{renderProductPrice({
i18n,
productListingData: bundleListingData,
usageType: 'default',
plainText: true,
})}
</Trans>
)
}
onClick={() => onDisplayBundle(bundleListingData)}
@@ -603,9 +520,7 @@ const BundlePreviewBanner = ({ onDisplayBundle, i18n, category }: Props) => {
)}
</div>
{courseTiles}
{!isMobile && (
<BundlePreviewTile bundleListingData={bundleListingData} />
)}
<BundlePreviewTile bundleListingData={bundleListingData} />
</ResponsiveLineStackLayout>
</Column>
</Paper>

View File

@@ -8,94 +8,14 @@ import {
type CreditsPackageListingData,
type IncludedRedemptionCode,
} from '../../Utils/GDevelopServices/Shop';
import { renderPriceFormatted } from '../ProductPriceTag';
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import Text from '../../UI/Text';
import { Column, Line, Spacer } from '../../UI/Grid';
import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout';
import {
getPlanIcon,
getPlanInferredNameFromId,
} from '../../Profile/Subscription/PlanCard';
import Coin from '../../Credits/Icons/Coin';
import { formatDurationOfRedemptionCode } from '../../RedemptionCode/Utils';
import School from '../../UI/CustomSvgIcons/School';
import Hammer from '../../UI/CustomSvgIcons/Hammer';
import Store from '../../UI/CustomSvgIcons/Store';
import { Divider } from '@material-ui/core';
import { getCreditsAmountFromId } from '../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
const styles = {
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
coinIcon: {
width: 13,
height: 13,
position: 'relative',
top: -1,
},
};
const getEstimatedRedemptionCodePrice = (
redemptionCode: IncludedRedemptionCode,
currencyCode: 'USD' | 'EUR'
): number => {
const planId = redemptionCode.givenSubscriptionPlanId;
if (!planId) {
return 0;
}
let estimatedAmountInCents = null;
if (redemptionCode.estimatedPrices) {
const estimatedPrice = redemptionCode.estimatedPrices.find(
price => price.currency === currencyCode
);
if (estimatedPrice) {
estimatedAmountInCents = estimatedPrice.value;
}
}
// If no estimated price is provided, guess a mostly correct value
// for backward compatibility.
if (estimatedAmountInCents === null) {
const monthlyEstimatedAmountInCents =
planId === 'gdevelop_silver'
? 599
: planId === 'gdevelop_gold'
? 1099
: planId === 'gdevelop_startup'
? 3499
: 0;
estimatedAmountInCents =
monthlyEstimatedAmountInCents *
Math.max(1, Math.round(redemptionCode.durationInDays / 30));
}
return estimatedAmountInCents;
};
export const renderEstimatedRedemptionCodePriceFormatted = (
bundleListingData: BundleListingData,
redemptionCode: IncludedRedemptionCode,
i18n: I18nType
): string => {
const bundlePrice = bundleListingData.prices.find(
price => price.usageType === 'default'
);
const currencyCode = bundlePrice ? bundlePrice.currency : 'USD';
const estimatedPrice = getEstimatedRedemptionCodePrice(
redemptionCode,
currencyCode
);
return renderPriceFormatted(estimatedPrice, currencyCode, i18n);
};
const getEstimatedTotalPriceAndCurrencyCode = ({
export const renderEstimatedTotalPriceFormatted = ({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
filter,
}: {
i18n: I18nType,
bundleListingData: ?BundleListingData,
productListingDatasIncludedInBundle: ?Array<
| PrivateAssetPackListingData
@@ -105,18 +25,7 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
| CreditsPackageListingData
>,
redemptionCodesIncludedInBundle: ?Array<IncludedRedemptionCode>,
filter?:
| 'ASSET_PACK'
| 'GAME_TEMPLATE'
| 'COURSE'
| 'BUNDLE'
| 'CREDITS_PACKAGE'
| 'REDEMPTION_CODE',
}): ?{
totalPrice: number,
bundlePrice: number,
currencyCode: 'USD' | 'EUR',
} => {
}): ?string => {
let totalPrice = 0;
if (
!bundleListingData ||
@@ -130,13 +39,10 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
price => price.usageType === 'default'
);
const currencyCode = bundlePrice ? bundlePrice.currency : 'USD';
const bundlePriceValue = bundlePrice ? bundlePrice.value : 0;
const currencySymbol = currencyCode === 'USD' ? '$' : '€';
for (const product of bundleListingData.includedListableProducts || []) {
if (
product.productType === 'ASSET_PACK' &&
(!filter || filter === 'ASSET_PACK')
) {
if (product.productType === 'ASSET_PACK') {
const listedAssetPack =
productListingDatasIncludedInBundle.find(
assetPack => assetPack.id === product.productId
@@ -147,10 +53,7 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
);
totalPrice += price ? price.value : 0;
}
} else if (
product.productType === 'GAME_TEMPLATE' &&
(!filter || filter === 'GAME_TEMPLATE')
) {
} else if (product.productType === 'GAME_TEMPLATE') {
const listedGameTemplate =
productListingDatasIncludedInBundle.find(
gameTemplate => gameTemplate.id === product.productId
@@ -161,10 +64,7 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
);
totalPrice += price ? price.value : 0;
}
} else if (
product.productType === 'COURSE' &&
(!filter || filter === 'COURSE')
) {
} else if (product.productType === 'COURSE') {
const listedCourse = productListingDatasIncludedInBundle.find(
course => course.id === product.productId
);
@@ -174,10 +74,7 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
);
totalPrice += price ? price.value : 0;
}
} else if (
product.productType === 'BUNDLE' &&
(!filter || filter === 'BUNDLE')
) {
} else if (product.productType === 'BUNDLE') {
const listedBundle = productListingDatasIncludedInBundle.find(
bundle => bundle.id === product.productId
);
@@ -187,10 +84,7 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
);
totalPrice += price ? price.value : 0;
}
} else if (
product.productType === 'CREDITS_PACKAGE' &&
(!filter || filter === 'CREDITS_PACKAGE')
) {
} else if (product.productType === 'CREDITS_PACKAGE') {
const listedCreditsPackage =
productListingDatasIncludedInBundle.find(
creditsPackage => creditsPackage.id === product.productId
@@ -204,522 +98,45 @@ const getEstimatedTotalPriceAndCurrencyCode = ({
}
}
if (
redemptionCodesIncludedInBundle.length > 0 &&
(!filter || filter === 'REDEMPTION_CODE')
) {
if (redemptionCodesIncludedInBundle.length > 0) {
for (const redemptionCode of redemptionCodesIncludedInBundle) {
const estimatedAmountInCents = getEstimatedRedemptionCodePrice(
redemptionCode,
currencyCode
);
totalPrice += estimatedAmountInCents || 0;
}
}
return { totalPrice, bundlePrice: bundlePriceValue, currencyCode };
};
export const renderEstimatedTotalPriceFormatted = ({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
filter,
}: {|
i18n: I18nType,
bundleListingData: ?BundleListingData,
productListingDatasIncludedInBundle: ?Array<
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
| CreditsPackageListingData
>,
redemptionCodesIncludedInBundle: ?Array<IncludedRedemptionCode>,
filter?:
| 'ASSET_PACK'
| 'GAME_TEMPLATE'
| 'COURSE'
| 'BUNDLE'
| 'CREDITS_PACKAGE'
| 'REDEMPTION_CODE',
|}): ?string => {
const estimatedPriceAndCode = getEstimatedTotalPriceAndCurrencyCode({
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
filter,
});
if (!estimatedPriceAndCode || !bundleListingData) return null;
return renderPriceFormatted(
estimatedPriceAndCode.totalPrice,
estimatedPriceAndCode.currencyCode,
i18n
);
};
export const getEstimatedSavingsFormatted = ({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}: {
i18n: I18nType,
bundleListingData: ?BundleListingData,
productListingDatasIncludedInBundle: ?Array<
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CourseListingData
| CreditsPackageListingData
>,
redemptionCodesIncludedInBundle: ?Array<IncludedRedemptionCode>,
}): ?{ savingsPriceFormatted: string, savingsPercentageFormatted: string } => {
const estimatedTotalPriceAndCode = getEstimatedTotalPriceAndCurrencyCode({
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
});
if (!estimatedTotalPriceAndCode || !bundleListingData) return null;
const savings =
estimatedTotalPriceAndCode.totalPrice -
estimatedTotalPriceAndCode.bundlePrice;
return {
savingsPriceFormatted: renderPriceFormatted(
savings,
estimatedTotalPriceAndCode.currencyCode,
i18n
),
savingsPercentageFormatted: `${Math.round(
(savings / estimatedTotalPriceAndCode.totalPrice) * 100
)}%`,
};
};
const getCoursesEstimatedHoursToComplete = (
courseListingDatas: CourseListingData[]
) => {
// Ideally we'd look at the Course durationInWeeks, but to avoid too
// many API calls, we just estimate.
const totalHours = courseListingDatas.reduce((acc, course) => {
const estimatedHours = 4 * 5; // Estimate 4h per day, 5 days a week.
return acc + Math.round(estimatedHours);
}, 0);
return Math.round(totalHours);
};
const CoursesLineSummary = ({
courseListingDatasIncludedInBundle,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}: {|
courseListingDatasIncludedInBundle: CourseListingData[],
bundleListingData: BundleListingData,
productListingDatasIncludedInBundle: (
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CreditsPackageListingData
| CourseListingData
)[],
redemptionCodesIncludedInBundle: IncludedRedemptionCode[],
|}) => (
<I18n>
{({ i18n }) => (
<LineStackLayout noMargin alignItems="flex-start">
<School />
<Column noMargin>
<Text noMargin size="sub-title">
<Trans>
{courseListingDatasIncludedInBundle.length === 1 ? (
<Trans>
{courseListingDatasIncludedInBundle.length} Course
</Trans>
) : (
<Trans>
{courseListingDatasIncludedInBundle.length} Courses
</Trans>
)}
</Trans>
</Text>
<Text color="secondary" noMargin>
<Trans>
{getCoursesEstimatedHoursToComplete(
courseListingDatasIncludedInBundle
)}{' '}
hours of material
</Trans>
</Text>
<Text color="secondary" noMargin>
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
filter: 'COURSE',
})}
</span>
</Text>
</Column>
</LineStackLayout>
)}
</I18n>
);
const AssetPacksLineSummary = ({
assetPackListingDatasIncludedInBundle,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}: {|
assetPackListingDatasIncludedInBundle: PrivateAssetPackListingData[],
bundleListingData: BundleListingData,
productListingDatasIncludedInBundle: (
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CreditsPackageListingData
| CourseListingData
)[],
redemptionCodesIncludedInBundle: IncludedRedemptionCode[],
|}) => (
<I18n>
{({ i18n }) => (
<LineStackLayout noMargin alignItems="flex-start">
<Store />
<Column noMargin>
<Text noMargin size="sub-title">
<Trans>
{assetPackListingDatasIncludedInBundle.length === 1 ? (
<Trans>
{assetPackListingDatasIncludedInBundle.length} Asset pack
</Trans>
) : (
<Trans>
{assetPackListingDatasIncludedInBundle.length} Asset packs
</Trans>
)}
</Trans>
</Text>
<Text color="secondary" noMargin>
<Trans>Commercial License</Trans>
</Text>
<Text color="secondary" noMargin>
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
filter: 'ASSET_PACK',
})}
</span>
</Text>
</Column>
</LineStackLayout>
)}
</I18n>
);
const GameTemplatesLineSummary = ({
gameTemplateListingDatasIncludedInBundle,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
}: {|
gameTemplateListingDatasIncludedInBundle: PrivateGameTemplateListingData[],
bundleListingData: BundleListingData,
productListingDatasIncludedInBundle: (
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CreditsPackageListingData
| CourseListingData
)[],
redemptionCodesIncludedInBundle: IncludedRedemptionCode[],
|}) => (
<I18n>
{({ i18n }) => (
<LineStackLayout noMargin alignItems="flex-start">
<Hammer />
<Column noMargin>
<Text noMargin size="sub-title">
<Trans>
{gameTemplateListingDatasIncludedInBundle.length === 1 ? (
<Trans>
{gameTemplateListingDatasIncludedInBundle.length} Game
template
</Trans>
) : (
<Trans>
{gameTemplateListingDatasIncludedInBundle.length} Game
templates
</Trans>
)}
</Trans>
</Text>
<Text color="secondary" noMargin>
<Trans>Lifetime access</Trans>
</Text>
<Text color="secondary" noMargin>
<span style={styles.discountedPrice}>
{renderEstimatedTotalPriceFormatted({
i18n,
bundleListingData,
productListingDatasIncludedInBundle,
redemptionCodesIncludedInBundle,
filter: 'GAME_TEMPLATE',
})}
</span>
</Text>
</Column>
</LineStackLayout>
)}
</I18n>
);
const RedemptionCodeLineSummary = ({
includedRedemptionCode,
bundleListingData,
}: {|
includedRedemptionCode: IncludedRedemptionCode,
bundleListingData: BundleListingData,
|}) => (
<I18n>
{({ i18n }) => (
<LineStackLayout noMargin alignItems="flex-start">
{getPlanIcon({
planId: includedRedemptionCode.givenSubscriptionPlanId,
logoSize: 20,
})}
<Column noMargin>
<Text noMargin size="sub-title">
<Trans>
{getPlanInferredNameFromId(
includedRedemptionCode.givenSubscriptionPlanId
)}
subscription
</Trans>
</Text>
<Text color="secondary" noMargin>
{formatDurationOfRedemptionCode(
includedRedemptionCode.durationInDays
)}
</Text>
<Text color="secondary" noMargin>
<span style={styles.discountedPrice}>
{renderEstimatedRedemptionCodePriceFormatted(
bundleListingData,
includedRedemptionCode,
i18n
)}
</span>
</Text>
</Column>
</LineStackLayout>
)}
</I18n>
);
const CreditsLineSummary = ({
includedCreditsAmount,
}: {
includedCreditsAmount: number,
}) => (
<LineStackLayout noMargin alignItems="center">
<Coin style={styles.coinIcon} />
<Text noMargin size="sub-title">
<Trans>{includedCreditsAmount} credits</Trans>
</Text>
</LineStackLayout>
);
export const getSummaryLines = ({
redemptionCodesIncludedInBundle,
bundleListingData,
productListingDatasIncludedInBundle,
}: {|
redemptionCodesIncludedInBundle: IncludedRedemptionCode[],
bundleListingData: BundleListingData,
productListingDatasIncludedInBundle: (
| PrivateAssetPackListingData
| PrivateGameTemplateListingData
| BundleListingData
| CreditsPackageListingData
| CourseListingData
)[],
|}) => {
const includedListableProducts =
bundleListingData.includedListableProducts || [];
const summaryLineItems = [];
const includedCourseListableProducts = includedListableProducts.filter(
product => product.productType === 'COURSE'
);
const courseListingDatasIncludedInBundle = includedCourseListableProducts
.map(product => {
// $FlowFixMe - We know it's a course because of the filter.
const courseListingData: ?CourseListingData = productListingDatasIncludedInBundle.find(
listingData =>
listingData.id === product.productId &&
listingData.productType === 'COURSE'
);
return courseListingData;
})
.filter(Boolean);
if (courseListingDatasIncludedInBundle.length) {
summaryLineItems.push(
<CoursesLineSummary
courseListingDatasIncludedInBundle={courseListingDatasIncludedInBundle}
bundleListingData={bundleListingData}
productListingDatasIncludedInBundle={
productListingDatasIncludedInBundle
const planId = redemptionCode.givenSubscriptionPlanId;
if (planId) {
let estimatedAmountInCents = null;
if (redemptionCode.estimatedPrices) {
const estimatedPrice = redemptionCode.estimatedPrices.find(
price => price.currency === currencyCode
);
if (estimatedPrice) {
estimatedAmountInCents = estimatedPrice.value;
}
}
redemptionCodesIncludedInBundle={redemptionCodesIncludedInBundle}
/>
);
}
const includedAssetPackListableProducts = includedListableProducts.filter(
product => product.productType === 'ASSET_PACK'
);
const assetPackListingDatasIncludedInBundle = includedAssetPackListableProducts
.map(product => {
// $FlowFixMe - We know it's an asset pack because of the filter.
const assetPackListingData: ?PrivateAssetPackListingData = productListingDatasIncludedInBundle.find(
listingData =>
listingData.id === product.productId &&
listingData.productType === 'ASSET_PACK'
);
return assetPackListingData;
})
.filter(Boolean);
if (assetPackListingDatasIncludedInBundle.length) {
summaryLineItems.push(
<AssetPacksLineSummary
assetPackListingDatasIncludedInBundle={
assetPackListingDatasIncludedInBundle
// If no estimated price is provided, guess a mostly correct value
// for backward compatibility.
if (estimatedAmountInCents === null) {
const monthlyEstimatedAmountInCents =
planId === 'gdevelop_silver'
? 599
: planId === 'gdevelop_gold'
? 1099
: planId === 'gdevelop_startup'
? 3499
: 0;
estimatedAmountInCents =
monthlyEstimatedAmountInCents *
Math.max(1, Math.round(redemptionCode.durationInDays / 30));
}
bundleListingData={bundleListingData}
productListingDatasIncludedInBundle={
productListingDatasIncludedInBundle
}
redemptionCodesIncludedInBundle={redemptionCodesIncludedInBundle}
/>
);
}
const includedGameTemplateListableProducts = includedListableProducts.filter(
product => product.productType === 'GAME_TEMPLATE'
);
const gameTemplateListingDatasIncludedInBundle = includedGameTemplateListableProducts
.map(product => {
// $FlowFixMe - We know it's a game template because of the filter.
const gameTemplateListingData: ?PrivateGameTemplateListingData = productListingDatasIncludedInBundle.find(
listingData =>
listingData.id === product.productId &&
listingData.productType === 'GAME_TEMPLATE'
);
return gameTemplateListingData;
})
.filter(Boolean);
if (gameTemplateListingDatasIncludedInBundle.length) {
summaryLineItems.push(
<GameTemplatesLineSummary
gameTemplateListingDatasIncludedInBundle={
gameTemplateListingDatasIncludedInBundle
}
bundleListingData={bundleListingData}
productListingDatasIncludedInBundle={
productListingDatasIncludedInBundle
}
redemptionCodesIncludedInBundle={redemptionCodesIncludedInBundle}
/>
);
}
if (redemptionCodesIncludedInBundle.length) {
redemptionCodesIncludedInBundle.forEach(code => {
summaryLineItems.push(
<RedemptionCodeLineSummary
includedRedemptionCode={code}
bundleListingData={bundleListingData}
/>
);
});
}
const includedCreditsAmount = includedListableProducts
.filter(product => product.productType === 'CREDIT_PACKAGE')
.reduce(
(total, product) => total + getCreditsAmountFromId(product.productId),
0
);
if (includedCreditsAmount) {
summaryLineItems.push(
<CreditsLineSummary includedCreditsAmount={includedCreditsAmount} />
);
}
const mobileLineItems = [];
const desktopLineItems = [];
summaryLineItems.forEach((item, index) => {
if (index !== 0) {
desktopLineItems.push(
<Line noMargin key={`divider-${index}`}>
<Spacer />
<Column>
<Divider orientation="vertical" />
</Column>
<Spacer />
</Line>
);
if (index % 2 === 1) {
mobileLineItems.push(
<Column key={`divider-${index}`}>
<Divider orientation="vertical" />
</Column>
);
totalPrice += estimatedAmountInCents || 0;
}
}
desktopLineItems.push(item);
mobileLineItems.push(
<Column expand noMargin key={`item-${index}`}>
{item}
</Column>
);
});
// Desktop, everything on one line.
const desktopLines = [];
desktopLines.push(
<ResponsiveLineStackLayout expand key="desktop-line">
{desktopLineItems}
</ResponsiveLineStackLayout>
);
// Mobile, 3 items by line (2 + 1 divider)
const mobileLines = [];
for (let i = 0; i < mobileLineItems.length; i += 3) {
mobileLines.push(
<LineStackLayout key={`mobile-line-${i}`} expand>
{mobileLineItems.slice(i, i + 3)}
</LineStackLayout>
);
}
return {
mobileLines,
desktopLines,
};
return `${currencySymbol} ${i18n
.number(totalPrice / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.replace(/\D00$/, '')}`;
};

View File

@@ -202,9 +202,7 @@ const PrivateAssetPackInformationPage = ({
CreditsPackageStoreContext
);
const [selectedUsageType, setSelectedUsageType] = React.useState<string>(
privateAssetPackListingData.prices.length
? privateAssetPackListingData.prices[0].usageType
: ''
privateAssetPackListingData.prices[0].usageType
);
const [
purchasingPrivateAssetPackListingData,
@@ -237,7 +235,9 @@ const PrivateAssetPackInformationPage = ({
const userAssetPackPurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: privateAssetPackListingData.id,
productId: privateAssetPackListingData
? privateAssetPackListingData.id
: null,
receivedProducts: [
...(receivedAssetPacks || []),
...(receivedBundles || []),
@@ -736,7 +736,10 @@ const PrivateAssetPackInformationPage = ({
label={<Trans>Browse assets</Trans>}
/>
) : (
<ColumnStackLayout noMargin>
<>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
{!errorText && (
<PurchaseProductButtons
i18n={i18n}
@@ -749,10 +752,7 @@ const PrivateAssetPackInformationPage = ({
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
</ColumnStackLayout>
</>
)}
</ColumnStackLayout>
</div>

View File

@@ -170,9 +170,7 @@ const PrivateGameTemplateInformationPage = ({
null
);
const [selectedUsageType, setSelectedUsageType] = React.useState<string>(
privateGameTemplateListingData.prices.length
? privateGameTemplateListingData.prices[0].usageType
: ''
privateGameTemplateListingData.prices[0].usageType
);
const [
purchasingPrivateGameTemplateListingData,
@@ -204,7 +202,9 @@ const PrivateGameTemplateInformationPage = ({
const userGameTemplatePurchaseUsageType = React.useMemo(
() =>
getUserProductPurchaseUsageType({
productId: privateGameTemplateListingData.id,
productId: privateGameTemplateListingData
? privateGameTemplateListingData.id
: null,
receivedProducts: [
...(receivedGameTemplates || []),
...(receivedBundles || []),
@@ -596,7 +596,10 @@ const PrivateGameTemplateInformationPage = ({
/>
<Spacer />
{!isAlreadyReceived ? (
<ColumnStackLayout noMargin>
<>
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
{!errorText && (
<PurchaseProductButtons
i18n={i18n}
@@ -611,10 +614,7 @@ const PrivateGameTemplateInformationPage = ({
onClickBuyWithCredits={onWillBuyWithCredits}
/>
)}
{!shouldUseOrSimulateAppStoreProduct && (
<SecureCheckout />
)}
</ColumnStackLayout>
</>
) : onCreateWithGameTemplate ? (
<OpenProductButton
productListingData={privateGameTemplateListingData}

View File

@@ -1,133 +0,0 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import Text from '../UI/Text';
import { Column, Spacer } from '../UI/Grid';
import { LineStackLayout } from '../UI/Layout';
const styles = {
limitedTimeContainer: {
display: 'flex',
backgroundColor: '#F03F18',
color: 'white',
borderRadius: 4,
padding: '8px 0',
},
};
type Props = {|
visibleUntil: string,
hideMinutesAndSeconds?: boolean,
alignCenter?: boolean,
|};
const ProductLimitedTimeOffer = ({
visibleUntil,
hideMinutesAndSeconds,
alignCenter,
}: Props) => {
const [timeLeft, setTimeLeft] = React.useState<{|
days: number,
hours: number,
minutes: number,
seconds: number,
|}>({ days: 0, hours: 0, minutes: 0, seconds: 0 });
React.useEffect(
() => {
const updateCountdown = () => {
const now = new Date().getTime();
const targetTime = new Date(visibleUntil).getTime();
const difference = targetTime - now;
if (difference > 0) {
setTimeLeft({
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor(
(difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
),
minutes: Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60)),
seconds: Math.floor((difference % (1000 * 60)) / 1000),
});
} else {
setTimeLeft({ days: 0, hours: 0, minutes: 0, seconds: 0 });
}
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
},
[visibleUntil]
);
if (!timeLeft) return null;
return (
<div style={styles.limitedTimeContainer}>
<Column
justifyContent="center"
expand
alignItems={alignCenter ? 'center' : undefined}
>
<Text>
<Trans>Limited time offer:</Trans>
</Text>
<LineStackLayout alignItems="center" noMargin>
<Text noMargin size="block-title">
{String(timeLeft.days).padStart(2, '0')}
</Text>
<Text noMargin>
<Trans>Days</Trans>
</Text>
<Spacer />
<Text
noMargin
size="block-title"
style={{
fontVariantNumeric: 'tabular-nums',
}}
>
{String(timeLeft.hours).padStart(2, '0')}
</Text>
<Text noMargin>
<Trans>Hours</Trans>
</Text>
{!hideMinutesAndSeconds && (
<LineStackLayout alignItems="center" noMargin>
<Spacer />
<Text
noMargin
size="block-title"
style={{
fontVariantNumeric: 'tabular-nums',
}}
>
{String(timeLeft.minutes).padStart(2, '0')}
</Text>
<Text noMargin>
<Trans>Minutes</Trans>
</Text>
<Spacer />
<Text
noMargin
size="block-title"
style={{
fontVariantNumeric: 'tabular-nums',
}}
>
{String(timeLeft.seconds).padStart(2, '0')}
</Text>
<Text noMargin>
<Trans>Seconds</Trans>
</Text>
</LineStackLayout>
)}
</LineStackLayout>
</Column>
</div>
);
};
export default ProductLimitedTimeOffer;

View File

@@ -319,7 +319,6 @@ export const getProductsIncludedInBundleTiles = ({
onPrivateGameTemplateOpen,
onBundleOpen,
onCourseOpen,
discountedPrice,
}: {|
product: ?PrivateAssetPack | PrivateGameTemplate | Bundle | Course,
productListingDatas: ?Array<
@@ -344,7 +343,6 @@ export const getProductsIncludedInBundleTiles = ({
) => void,
onBundleOpen?: (bundleListingData: BundleListingData) => void,
onCourseOpen?: (courseListingData: CourseListingData) => void,
discountedPrice?: boolean,
|}): ?Array<React.Node> => {
if (!product || !productListingDatas) return null;
@@ -380,7 +378,6 @@ export const getProductsIncludedInBundleTiles = ({
onPrivateGameTemplateOpen(includedProductListingData)
}
owned={isProductOwned}
discountedPrice={discountedPrice}
/>
);
}
@@ -398,7 +395,6 @@ export const getProductsIncludedInBundleTiles = ({
key={includedProductListingData.id}
onSelect={() => onPrivateAssetPackOpen(includedProductListingData)}
owned={isProductOwned}
discountedPrice={discountedPrice}
/>
);
}
@@ -416,7 +412,6 @@ export const getProductsIncludedInBundleTiles = ({
key={includedProductListingData.id}
onSelect={() => onBundleOpen(includedProductListingData)}
owned={isProductOwned}
discountedPrice={discountedPrice}
/>
);
}
@@ -434,7 +429,6 @@ export const getProductsIncludedInBundleTiles = ({
key={includedProductListingData.id}
onSelect={() => onCourseOpen(includedProductListingData)}
owned={isProductOwned}
discountedPrice={discountedPrice}
/>
);
}
@@ -535,7 +529,6 @@ export const PurchaseProductButtons = <
isAlreadyReceived,
onClickBuy,
onClickBuyWithCredits,
customLabel,
}: {|
productListingData: T,
selectedUsageType: string,
@@ -545,7 +538,6 @@ export const PurchaseProductButtons = <
isAlreadyReceived: boolean,
onClickBuy: () => void | Promise<void>,
onClickBuyWithCredits?: () => void | Promise<void>,
customLabel?: React.Node,
|}) => {
const { authenticated } = React.useContext(AuthenticatedUserContext);
const shouldUseOrSimulateAppStoreProduct =
@@ -586,13 +578,10 @@ export const PurchaseProductButtons = <
<LineStackLayout>
<RaisedButton
primary
label={
customLabel || <Trans>Buy for {formattedProductPriceText}</Trans>
}
label={<Trans>Buy for {formattedProductPriceText}</Trans>}
onClick={onClickBuyWithCredits}
id={`buy-${productType}-with-credits`}
icon={<Coin fontSize="small" />}
size="medium"
/>
{!isAlreadyReceived && !authenticated && (
<Text size="body-small">
@@ -603,27 +592,21 @@ export const PurchaseProductButtons = <
)}
</LineStackLayout>
) : (
<LineStackLayout noMargin>
<LineStackLayout>
{creditPrice && (
<FlatButton
primary
label={
customLabel || <Trans>Buy for {creditPrice.amount} credits</Trans>
}
label={<Trans>Buy for {creditPrice.amount} credits</Trans>}
onClick={onClickBuyWithCredits}
id={`buy-${productType}-with-credits`}
leftIcon={<Coin fontSize="small" />}
size="medium"
/>
)}
<RaisedButton
primary
label={
customLabel || <Trans>Buy for {formattedProductPriceText}</Trans>
}
label={<Trans>Buy for {formattedProductPriceText}</Trans>}
onClick={onClickBuy}
id={`buy-${productType}`}
size="medium"
/>
</LineStackLayout>
);

View File

@@ -38,22 +38,6 @@ const styles = {
marginTop: -3,
marginBottom: -1,
},
discountedPrice: { textDecoration: 'line-through', opacity: 0.7 },
};
export const renderPriceFormatted = (
priceInCents: number,
currencyCode: 'USD' | 'EUR',
i18n: I18nType
): string => {
const currencySymbol = currencyCode === 'USD' ? '$' : '€';
// Use a non-breaking space to ensure the currency stays next to the price.
return `${currencySymbol}\u00A0${i18n
.number(priceInCents / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.replace(/\D00$/, '')}`;
};
type FormatProps = {|
@@ -67,7 +51,6 @@ type FormatProps = {|
usageType?: string,
plainText?: boolean,
showBothPrices?: 'column' | 'line', // If defined, will show both the credits price and the product price.
discountedPrice?: boolean,
|};
export const renderProductPrice = ({
@@ -76,7 +59,6 @@ export const renderProductPrice = ({
usageType,
plainText,
showBothPrices,
discountedPrice,
}: FormatProps): React.Node => {
// For Credits packages & Bundles, on mobile, only show the app store product price.
if (
@@ -97,9 +79,8 @@ export const renderProductPrice = ({
? creditPrices[0]
: null;
// If we're on mobile, only show credits prices for other products,
// except if we're showing the discounted price.
if (shouldUseAppStoreProduct() && !discountedPrice) {
// If we're on mobile, only show credits prices for other packages.
if (shouldUseAppStoreProduct()) {
if (!creditPrice) return '';
return plainText ? (
i18n._(t`${creditPrice.amount} credits`)
@@ -122,15 +103,17 @@ export const renderProductPrice = ({
: null;
if (!price) return '';
const formattedPrice = renderPriceFormatted(
price.value,
price.currency,
i18n
);
const currencyCode = price.currency === 'USD' ? '$' : '€';
const formattedPrice = `${currencyCode} ${i18n
.number(price.value / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
.replace(/\D00$/, '')}`;
return plainText ? (
formattedPrice
) : showBothPrices && creditPrice && !discountedPrice ? (
) : showBothPrices && creditPrice ? (
showBothPrices === 'column' ? (
<Column alignItems="flex-end">
<div style={styles.creditPriceContainer}>
@@ -166,11 +149,7 @@ export const renderProductPrice = ({
)
) : (
<Text noMargin size="sub-title" color="inherit">
{discountedPrice ? (
<span style={styles.discountedPrice}>{formattedPrice}</span>
) : (
formattedPrice
)}
{formattedPrice}
</Text>
);
};
@@ -186,7 +165,6 @@ type ProductPriceOrOwnedProps = {|
usageType?: string,
owned?: boolean,
showBothPrices?: 'column' | 'line',
discountedPrice?: boolean,
|};
export const OwnedLabel = () => {
@@ -211,18 +189,11 @@ export const getProductPriceOrOwnedLabel = ({
usageType,
owned,
showBothPrices,
discountedPrice,
}: ProductPriceOrOwnedProps): React.Node => {
return owned ? (
<OwnedLabel />
) : (
renderProductPrice({
i18n,
productListingData,
usageType,
showBothPrices,
discountedPrice,
})
renderProductPrice({ i18n, productListingData, usageType, showBothPrices })
);
};
@@ -240,7 +211,6 @@ type ProductPriceTagProps = {|
*/
withOverlay?: boolean,
owned?: boolean,
discountedPrice?: boolean,
|};
const ProductPriceTag = ({
@@ -248,7 +218,6 @@ const ProductPriceTag = ({
usageType,
withOverlay,
owned,
discountedPrice,
}: ProductPriceTagProps) => {
return (
<I18n>
@@ -258,7 +227,6 @@ const ProductPriceTag = ({
productListingData,
usageType,
owned,
discountedPrice,
});
return <PriceTag withOverlay={withOverlay} label={label} />;

View File

@@ -1,23 +1,52 @@
// @flow
import React from 'react';
import { LineStackLayout } from '../../UI/Layout';
import Visa from '../../UI/CustomSvgIcons/Visa';
import MasterCard from '../../UI/CustomSvgIcons/MasterCard';
import Paypal from '../../UI/CustomSvgIcons/Paypal';
import Text from '../../UI/Text';
import { Trans } from '@lingui/macro';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import ShieldChecked from '../../UI/CustomSvgIcons/ShieldChecked';
const SecureCheckout = () => {
const styles = {
logoContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '4px 8px',
borderRadius: 8,
},
};
const LogoContainer = ({ children }) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
<LineStackLayout noMargin alignItems="center">
<ShieldChecked style={{ color: gdevelopTheme.message.valid }} />
<Text color="secondary">
<Trans>Paypal secure</Trans>
</Text>
<ShieldChecked style={{ color: gdevelopTheme.message.valid }} />
<Text color="secondary">
<Trans>Stripe secure</Trans>
<div
style={{
...styles.logoContainer,
background: gdevelopTheme.palette.secondary,
}}
>
{children}
</div>
);
};
const SecureCheckout = () => {
return (
<LineStackLayout>
<Text>
<Trans>Secure Checkout:</Trans>
</Text>
<LogoContainer>
<Paypal />
</LogoContainer>
<LogoContainer>
<Visa />
</LogoContainer>
<LogoContainer>
<MasterCard />
</LogoContainer>
</LineStackLayout>
);
};

View File

@@ -244,7 +244,6 @@ export const PrivateAssetPackTile = ({
style,
owned,
disabled,
discountedPrice,
}: {|
assetPackListingData: PrivateAssetPackListingData,
onSelect: () => void,
@@ -252,7 +251,6 @@ export const PrivateAssetPackTile = ({
style?: any,
owned: boolean,
disabled?: boolean,
discountedPrice?: boolean,
|}) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
return (
@@ -293,7 +291,6 @@ export const PrivateAssetPackTile = ({
productListingData={assetPackListingData}
withOverlay
owned={owned}
discountedPrice={discountedPrice}
/>
</div>
<Column>
@@ -467,7 +464,6 @@ export const PrivateGameTemplateTile = ({
style,
owned,
disabled,
discountedPrice,
}: {|
privateGameTemplateListingData: PrivateGameTemplateListingData,
onSelect: () => void,
@@ -475,7 +471,6 @@ export const PrivateGameTemplateTile = ({
style?: any,
owned: boolean,
disabled?: boolean,
discountedPrice?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
@@ -505,7 +500,6 @@ export const PrivateGameTemplateTile = ({
productListingData={privateGameTemplateListingData}
withOverlay
owned={owned}
discountedPrice={discountedPrice}
/>
</div>
<Column>
@@ -528,7 +522,6 @@ export const CourseTile = ({
style,
owned,
disabled,
discountedPrice,
}: {|
courseListingData: CourseListingData,
onSelect: () => void,
@@ -536,7 +529,6 @@ export const CourseTile = ({
style?: any,
owned: boolean,
disabled?: boolean,
discountedPrice?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
@@ -564,7 +556,6 @@ export const CourseTile = ({
productListingData={courseListingData}
withOverlay
owned={owned}
discountedPrice={discountedPrice}
/>
</div>
<Column>
@@ -587,7 +578,6 @@ export const BundleTile = ({
style,
owned,
disabled,
discountedPrice,
}: {|
bundleListingData: BundleListingData,
onSelect: () => void,
@@ -595,7 +585,6 @@ export const BundleTile = ({
style?: any,
owned: boolean,
disabled?: boolean,
discountedPrice?: boolean,
|}) => {
const { isMobile } = useResponsiveWindowSize();
const gdevelopTheme = React.useContext(GDevelopThemeContext);
@@ -627,7 +616,6 @@ export const BundleTile = ({
productListingData={bundleListingData}
withOverlay
owned={owned}
discountedPrice={discountedPrice}
/>
) : (
<OwnedLabel />

View File

@@ -1,6 +1,5 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import ChevronArrowLeft from '../UI/CustomSvgIcons/ChevronArrowLeft';
import Tune from '../UI/CustomSvgIcons/Tune';
@@ -69,8 +68,6 @@ import { AssetSwappingAssetStoreSearchFilter } from './AssetStoreSearchFilter';
import { delay } from '../Utils/Delay';
import { BundleStoreContext } from './Bundles/BundleStoreContext';
import BundleInformationPage from './Bundles/BundleInformationPage';
import { type CourseCompletion } from '../MainFrame/EditorContainers/HomePage/UseCourses';
import { type SubscriptionPlanWithPricingSystems } from '../Utils/GDevelopServices/Usage';
type Props = {|
onlyShowAssets?: boolean, // TODO: if we add more options, use an array instead.
@@ -79,11 +76,8 @@ type Props = {|
privateGameTemplateListingData: PrivateGameTemplateListingData
) => void,
onOpenProfile?: () => void,
courses?: ?Array<Course>,
receivedCourses?: ?Array<Course>,
onCourseOpen?: (courseId: string) => void,
getSubscriptionPlansWithPricingSystems?: () => Array<SubscriptionPlanWithPricingSystems> | null,
getCourseCompletion?: (courseId: string) => CourseCompletion | null,
assetSwappedObject?: ?gdObject,
minimalUI?: boolean,
|};
@@ -124,11 +118,8 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
displayPromotions,
onOpenPrivateGameTemplateListingData,
onOpenProfile,
courses,
receivedCourses,
onCourseOpen,
getSubscriptionPlansWithPricingSystems,
getCourseCompletion,
assetSwappedObject,
minimalUI,
}: Props,
@@ -669,323 +660,291 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
]
);
const onBack = React.useCallback(
async () => {
const page = shopNavigationState.backToPreviousPage();
const isUpdatingSearchtext = reApplySearchTextIfNeeded(page);
if (isUpdatingSearchtext) {
// Updating the search is not instant, so we cannot apply the scroll position
// right away. We force a wait as there's no easy way to know when results are completely updated.
await delay(500);
setScrollUpdateIsNeeded(page);
applyBackScrollPosition(page); // We apply it manually, because the layout effect won't be called again.
} else {
setScrollUpdateIsNeeded(page);
}
},
[
shopNavigationState,
reApplySearchTextIfNeeded,
setScrollUpdateIsNeeded,
applyBackScrollPosition,
]
);
return (
<I18n>
{({ i18n }) => (
<Column
expand
noMargin
useFullHeight
noOverflowParent
id="asset-store"
>
<>
<LineStackLayout>
{!(assetSwappedObject && minimalUI) && (
<IconButton
id="home-button"
key="back-discover"
tooltip={t`Back to discover`}
onClick={() => {
setSearchText('');
const page = assetSwappedObject
? shopNavigationState.openAssetSwapping()
: shopNavigationState.openHome();
setScrollUpdateIsNeeded(page);
clearAllAssetStoreFilters();
setIsFiltersPanelOpen(false);
}}
size="small"
>
<Home />
</IconButton>
)}
<Column expand useFullHeight noMargin>
<SearchBar
placeholder={
onlyShowAssets ? t`Search assets` : t`Search the shop`
<Column expand noMargin useFullHeight noOverflowParent id="asset-store">
<>
<LineStackLayout>
{!(assetSwappedObject && minimalUI) && (
<IconButton
id="home-button"
key="back-discover"
tooltip={t`Back to discover`}
onClick={() => {
setSearchText('');
const page = assetSwappedObject
? shopNavigationState.openAssetSwapping()
: shopNavigationState.openHome();
setScrollUpdateIsNeeded(page);
clearAllAssetStoreFilters();
setIsFiltersPanelOpen(false);
}}
size="small"
>
<Home />
</IconButton>
)}
<Column expand useFullHeight noMargin>
<SearchBar
placeholder={
onlyShowAssets ? t`Search assets` : t`Search the shop`
}
value={searchText}
onChange={(newValue: string) => {
if (searchText === newValue || newValue.length === 1) {
return;
}
setSearchText(newValue);
if (isOnSearchResultPage) {
// An existing search is already being done: just move to the
// top search results.
shopNavigationState.openSearchResultPage();
const assetsListInterface = assetsList.current;
if (assetsListInterface) {
assetsListInterface.scrollToPosition(0);
assetsListInterface.setPageBreakIndex(0);
}
value={searchText}
onChange={(newValue: string) => {
if (searchText === newValue || newValue.length === 1) {
return;
}
setSearchText(newValue);
if (isOnSearchResultPage) {
// An existing search is already being done: just move to the
// top search results.
shopNavigationState.openSearchResultPage();
const assetsListInterface = assetsList.current;
if (assetsListInterface) {
assetsListInterface.scrollToPosition(0);
assetsListInterface.setPageBreakIndex(0);
}
} else {
// A new search is being initiated: navigate to the search page,
// and clear the history as a new search was launched.
if (!!newValue) {
shopNavigationState.clearHistory();
shopNavigationState.openSearchResultPage();
openFiltersPanelIfAppropriate();
}
}
}}
onRequestSearch={() => {}}
ref={searchBar}
id="asset-store-search-bar"
/>
</Column>
{!(assetSwappedObject && minimalUI) && (
<IconButton
onClick={() => setIsFiltersPanelOpen(!isFiltersPanelOpen)}
disabled={!canShowFiltersPanel}
selected={canShowFiltersPanel && isFiltersPanelOpen}
size="small"
>
<Tune />
</IconButton>
)}
</LineStackLayout>
<Spacer />
</>
<Column noMargin>
<Line justifyContent="space-between" noMargin alignItems="center">
{(!isOnHomePage || !!openedShopCategory) &&
!(assetSwappedObject && minimalUI) && (
} else {
// A new search is being initiated: navigate to the search page,
// and clear the history as a new search was launched.
if (!!newValue) {
shopNavigationState.clearHistory();
shopNavigationState.openSearchResultPage();
openFiltersPanelIfAppropriate();
}
}
}}
onRequestSearch={() => {}}
ref={searchBar}
id="asset-store-search-bar"
/>
</Column>
{!(assetSwappedObject && minimalUI) && (
<IconButton
onClick={() => setIsFiltersPanelOpen(!isFiltersPanelOpen)}
disabled={!canShowFiltersPanel}
selected={canShowFiltersPanel && isFiltersPanelOpen}
size="small"
>
<Tune />
</IconButton>
)}
</LineStackLayout>
<Spacer />
</>
<Column noMargin>
<Line justifyContent="space-between" noMargin alignItems="center">
{(!isOnHomePage || !!openedShopCategory) &&
!(assetSwappedObject && minimalUI) && (
<>
{shopNavigationState.isRootPage ? null : (
<Column expand alignItems="flex-start" noMargin>
<TextButton
icon={<ChevronArrowLeft />}
label={<Trans>Back</Trans>}
onClick={async () => {
const page = shopNavigationState.backToPreviousPage();
const isUpdatingSearchtext = reApplySearchTextIfNeeded(
page
);
if (isUpdatingSearchtext) {
// Updating the search is not instant, so we cannot apply the scroll position
// right away. We force a wait as there's no easy way to know when results are completely updated.
await delay(500);
setScrollUpdateIsNeeded(page);
applyBackScrollPosition(page); // We apply it manually, because the layout effect won't be called again.
} else {
setScrollUpdateIsNeeded(page);
}
}}
/>
</Column>
)}
{(openedAssetPack ||
openedPrivateAssetPackListingData ||
filtersState.chosenCategory) && (
<>
{shopNavigationState.isRootPage ||
// Don't show back action on bundle pages, as it's handled by the page itself.
openedBundleListingData ? null : (
<Column expand alignItems="flex-start" noMargin>
<TextButton
icon={<ChevronArrowLeft />}
label={<Trans>Back</Trans>}
onClick={onBack}
/>
{!openedAssetPack && !openedPrivateAssetPackListingData && (
// Only show the category name if we're not on an asset pack page.
<Column expand alignItems="center">
<Text size="block-title" noMargin>
{filtersState.chosenCategory
? capitalize(
filtersState.chosenCategory.node.name
)
: ''}
</Text>
</Column>
)}
{(openedAssetPack ||
openedPrivateAssetPackListingData ||
filtersState.chosenCategory) && (
<>
{!openedAssetPack &&
!openedPrivateAssetPackListingData && (
// Only show the category name if we're not on an asset pack page.
<Column expand alignItems="center">
<Text size="block-title" noMargin>
{filtersState.chosenCategory
? capitalize(
filtersState.chosenCategory.node.name
)
: ''}
</Text>
</Column>
)}
<Column
expand
alignItems="flex-end"
noMargin
justifyContent="center"
>
{openedAssetPack &&
openedAssetPack.content &&
doesAssetPackContainAudio(openedAssetPack) &&
!isAssetPackAudioOnly(openedAssetPack) ? (
<PrivateAssetPackAudioFilesDownloadButton
assetPack={openedAssetPack}
/>
) : null}
</Column>
</>
)}
<Column
expand
alignItems="flex-end"
noMargin
justifyContent="center"
>
{openedAssetPack &&
openedAssetPack.content &&
doesAssetPackContainAudio(openedAssetPack) &&
!isAssetPackAudioOnly(openedAssetPack) ? (
<PrivateAssetPackAudioFilesDownloadButton
assetPack={openedAssetPack}
/>
) : null}
</Column>
</>
)}
</Line>
</Column>
<Line
expand
noMargin
overflow={
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
}
>
{isOnHomePage ? (
storeError ? (
<PlaceholderError onRetry={fetchAssetsAndGameTemplates}>
<AlertMessage kind="error">
<Trans>
An error occurred when fetching the store content.
Please try again later.
</Trans>
</AlertMessage>
</PlaceholderError>
) : publicAssetPacks &&
privateAssetPackListingDatas &&
privateGameTemplateListingDatas &&
bundleListingDatas ? (
<AssetsHome
ref={assetsHome}
publicAssetPacks={publicAssetPacks}
privateAssetPackListingDatas={privateAssetPackListingDatas}
privateGameTemplateListingDatas={
privateGameTemplateListingDatas
}
bundleListingDatas={bundleListingDatas}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onCategorySelection={selectShopCategory}
openedShopCategory={openedShopCategory}
onlyShowAssets={onlyShowAssets}
displayPromotions={displayPromotions}
onOpenProfile={onOpenProfile}
/>
) : (
<PlaceholderLoader />
)
) : isOnSearchResultPage ? (
<AssetsList
publicAssetPacks={
assetSwappedObject ? [] : publicAssetPacksSearchResults
}
privateAssetPackListingDatas={
assetSwappedObject
? []
: privateAssetPackListingDatasSearchResults
}
privateGameTemplateListingDatas={
assetSwappedObject
? []
: privateGameTemplateListingDatasSearchResults
}
bundleListingDatas={
assetSwappedObject ? [] : bundleListingDatasSearchResults
}
assetShortHeaders={assetShortHeadersSearchResults}
ref={assetsList}
error={storeError}
onOpenDetails={onOpenDetails}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onFolderSelection={selectFolder}
onGoBackToFolderIndex={goBackToFolderIndex}
currentPage={shopNavigationState.getCurrentPage()}
onlyShowAssets={onlyShowAssets}
hideDetails={!!assetSwappedObject && !!minimalUI}
/>
) : // Do not show the asset details if we're swapping an asset.
openedAssetShortHeader && !(assetSwappedObject && minimalUI) ? (
<AssetDetails
ref={assetDetails}
onTagSelection={selectTag}
assetShortHeader={openedAssetShortHeader}
onOpenDetails={onOpenDetails}
onAssetLoaded={() => applyBackScrollPosition(currentPage)}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
/>
) : !!openedPrivateAssetPackListingData ? (
<PrivateAssetPackInformationPage
privateAssetPackListingData={
openedPrivateAssetPackListingData
}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateAssetPackListingDatasFromSameCreator={
privateAssetPackListingDatasFromSameCreator
}
/>
) : !!openedPrivateGameTemplateListingData ? (
<PrivateGameTemplateInformationPage
privateGameTemplateListingData={
openedPrivateGameTemplateListingData
}
onCreateWithGameTemplate={() => {
onOpenPrivateGameTemplateListingData &&
onOpenPrivateGameTemplateListingData(
openedPrivateGameTemplateListingData
);
}}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateGameTemplateListingDatasFromSameCreator={
privateGameTemplateListingDatasFromSameCreator
}
/>
) : !!openedBundleListingData &&
getSubscriptionPlansWithPricingSystems &&
getCourseCompletion ? (
<BundleInformationPage
bundleListingData={openedBundleListingData}
noPadding
onBack={onBack}
onBundleOpen={selectBundle}
onGameTemplateOpen={selectPrivateGameTemplate}
onAssetPackOpen={selectPrivateAssetPack}
onCourseOpen={selectCourse}
getSubscriptionPlansWithPricingSystems={
getSubscriptionPlansWithPricingSystems
}
courses={courses}
receivedCourses={receivedCourses}
getCourseCompletion={getCourseCompletion}
/>
) : null}
{canShowFiltersPanel && (
<ResponsivePaperOrDrawer
onClose={() => setIsFiltersPanelOpen(false)}
open={isFiltersPanelOpen}
>
<ScrollView>
<Column>
<Column noMargin>
<Line alignItems="center">
<Tune />
<Subheader>
<Trans>Object filters</Trans>
</Subheader>
</Line>
</Column>
<Line justifyContent="space-between" alignItems="center">
<AssetStoreFilterPanel
assetSwappedObject={assetSwappedObject}
/>
</Line>
</Column>
</ScrollView>
</ResponsivePaperOrDrawer>
</>
)}
</Line>
</Column>
)}
</I18n>
</Line>
</Column>
<Line
expand
noMargin
overflow={
'hidden' /* Somehow required on Chrome/Firefox to avoid children growing (but not on Safari) */
}
>
{isOnHomePage ? (
storeError ? (
<PlaceholderError onRetry={fetchAssetsAndGameTemplates}>
<AlertMessage kind="error">
<Trans>
An error occurred when fetching the store content. Please
try again later.
</Trans>
</AlertMessage>
</PlaceholderError>
) : publicAssetPacks &&
privateAssetPackListingDatas &&
privateGameTemplateListingDatas &&
bundleListingDatas ? (
<AssetsHome
ref={assetsHome}
publicAssetPacks={publicAssetPacks}
privateAssetPackListingDatas={privateAssetPackListingDatas}
privateGameTemplateListingDatas={
privateGameTemplateListingDatas
}
bundleListingDatas={bundleListingDatas}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onCategorySelection={selectShopCategory}
openedShopCategory={openedShopCategory}
onlyShowAssets={onlyShowAssets}
displayPromotions={displayPromotions}
onOpenProfile={onOpenProfile}
/>
) : (
<PlaceholderLoader />
)
) : isOnSearchResultPage ? (
<AssetsList
publicAssetPacks={
assetSwappedObject ? [] : publicAssetPacksSearchResults
}
privateAssetPackListingDatas={
assetSwappedObject
? []
: privateAssetPackListingDatasSearchResults
}
privateGameTemplateListingDatas={
assetSwappedObject
? []
: privateGameTemplateListingDatasSearchResults
}
bundleListingDatas={
assetSwappedObject ? [] : bundleListingDatasSearchResults
}
assetShortHeaders={assetShortHeadersSearchResults}
ref={assetsList}
error={storeError}
onOpenDetails={onOpenDetails}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPublicAssetPackSelection={selectPublicAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
onBundleSelection={selectBundle}
onFolderSelection={selectFolder}
onGoBackToFolderIndex={goBackToFolderIndex}
currentPage={shopNavigationState.getCurrentPage()}
onlyShowAssets={onlyShowAssets}
hideDetails={!!assetSwappedObject && !!minimalUI}
/>
) : // Do not show the asset details if we're swapping an asset.
openedAssetShortHeader && !(assetSwappedObject && minimalUI) ? (
<AssetDetails
ref={assetDetails}
onTagSelection={selectTag}
assetShortHeader={openedAssetShortHeader}
onOpenDetails={onOpenDetails}
onAssetLoaded={() => applyBackScrollPosition(currentPage)}
onPrivateAssetPackSelection={selectPrivateAssetPack}
onPrivateGameTemplateSelection={selectPrivateGameTemplate}
/>
) : !!openedPrivateAssetPackListingData ? (
<PrivateAssetPackInformationPage
privateAssetPackListingData={openedPrivateAssetPackListingData}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateAssetPackListingDatasFromSameCreator={
privateAssetPackListingDatasFromSameCreator
}
/>
) : !!openedPrivateGameTemplateListingData ? (
<PrivateGameTemplateInformationPage
privateGameTemplateListingData={
openedPrivateGameTemplateListingData
}
onCreateWithGameTemplate={() => {
onOpenPrivateGameTemplateListingData &&
onOpenPrivateGameTemplateListingData(
openedPrivateGameTemplateListingData
);
}}
onAssetPackOpen={selectPrivateAssetPack}
onGameTemplateOpen={selectPrivateGameTemplate}
onBundleOpen={selectBundle}
privateGameTemplateListingDatasFromSameCreator={
privateGameTemplateListingDatasFromSameCreator
}
/>
) : !!openedBundleListingData ? (
<BundleInformationPage
bundleListingData={openedBundleListingData}
receivedCourses={receivedCourses}
onBundleOpen={selectBundle}
onGameTemplateOpen={selectPrivateGameTemplate}
onAssetPackOpen={selectPrivateAssetPack}
onCourseOpen={selectCourse}
/>
) : null}
{canShowFiltersPanel && (
<ResponsivePaperOrDrawer
onClose={() => setIsFiltersPanelOpen(false)}
open={isFiltersPanelOpen}
>
<ScrollView>
<Column>
<Column noMargin>
<Line alignItems="center">
<Tune />
<Subheader>
<Trans>Object filters</Trans>
</Subheader>
</Line>
</Column>
<Line justifyContent="space-between" alignItems="center">
<AssetStoreFilterPanel
assetSwappedObject={assetSwappedObject}
/>
</Line>
</Column>
</ScrollView>
</ResponsivePaperOrDrawer>
)}
</Line>
</Column>
);
}
);

View File

@@ -29,7 +29,6 @@ type Props = {|
eventsFunctionsExtension: gdEventsFunctionsExtension | null,
objectType: string,
objectBehaviorsTypes: Array<string>,
isChildObject: boolean,
open: boolean,
onClose: () => void,
onChoose: (type: string, defaultName: string) => void,
@@ -44,7 +43,6 @@ export default function NewBehaviorDialog({
onChoose,
objectType,
objectBehaviorsTypes,
isChildObject,
onExtensionInstalled,
}: Props) {
const [isInstalling, setIsInstalling] = React.useState(false);
@@ -226,7 +224,6 @@ export default function NewBehaviorDialog({
project={project}
objectType={objectType}
objectBehaviorsTypes={objectBehaviorsTypes}
isChildObject={isChildObject}
isInstalling={isInstalling}
onInstall={async shortHeader =>
onInstallExtension(i18n, shortHeader)

View File

@@ -71,7 +71,6 @@ type BehaviorConfigurationEditorProps = {|
project: gdProject,
object: gdObject,
behavior: gdBehavior,
isChildObject: boolean,
resourceManagementProps: ResourceManagementProps,
onBehaviorsUpdated: () => void,
onChangeBehaviorName: (behavior: gdBehavior, newName: string) => void,
@@ -95,7 +94,6 @@ const BehaviorConfigurationEditor = React.forwardRef<
project,
object,
behavior,
isChildObject,
resourceManagementProps,
onBehaviorsUpdated,
onChangeBehaviorName,
@@ -313,7 +311,6 @@ type UseManageBehaviorsState = {|
export const useManageObjectBehaviors = ({
project,
object,
isChildObject,
eventsFunctionsExtension,
onUpdate,
onSizeUpdated,
@@ -323,7 +320,6 @@ export const useManageObjectBehaviors = ({
}: {
project: gdProject,
object: gdObject,
isChildObject: boolean,
eventsFunctionsExtension: gdEventsFunctionsExtension | null,
onUpdate: () => void,
onSizeUpdated?: ?() => void,
@@ -590,7 +586,6 @@ export const useManageObjectBehaviors = ({
open
objectType={object.getType()}
objectBehaviorsTypes={listObjectBehaviorsTypes(object)}
isChildObject={isChildObject}
onClose={() => setNewBehaviorDialogOpen(false)}
onChoose={addBehavior}
project={project}
@@ -620,7 +615,6 @@ type Props = {|
project: gdProject,
eventsFunctionsExtension: gdEventsFunctionsExtension | null,
object: gdObject,
isChildObject: boolean,
onUpdateBehaviorsSharedData: () => void,
onSizeUpdated?: ?() => void,
resourceManagementProps: ResourceManagementProps,
@@ -642,7 +636,6 @@ const BehaviorsEditor = (props: Props) => {
const {
object,
isChildObject,
project,
eventsFunctionsExtension,
onSizeUpdated,
@@ -672,7 +665,6 @@ const BehaviorsEditor = (props: Props) => {
} = useManageObjectBehaviors({
project,
object,
isChildObject,
eventsFunctionsExtension,
onUpdate: forceUpdate,
onSizeUpdated,
@@ -793,7 +785,6 @@ const BehaviorsEditor = (props: Props) => {
key={behaviorName}
project={project}
object={object}
isChildObject={isChildObject}
behavior={behavior}
copyBehavior={copyBehavior}
onRemoveBehavior={removeBehavior}

View File

@@ -3,10 +3,8 @@ import { unserializeFromJSObject } from '../Utils/Serializer';
import {
type AiGeneratedEventChange,
type AiGeneratedEventUndeclaredVariable,
type AiGeneratedEventMissingObjectBehavior,
} from '../Utils/GDevelopServices/Generation';
import { mapFor } from '../Utils/MapFor';
import { isBehaviorDefaultCapability } from '../BehaviorsEditor/EnumerateBehaviorsMetadata';
const gd: libGDevelop = global.gd;
@@ -522,7 +520,8 @@ export const addObjectUndeclaredVariables = ({
.getVariables()
.insertNew(undeclaredVariable.name, 0);
setupVariable(variable, undeclaredVariable.type);
} else if (
}
if (
project
.getObjects()
.getObjectGroups()
@@ -537,129 +536,3 @@ export const addObjectUndeclaredVariables = ({
}
});
};
export const addMissingObjectBehaviors = ({
project,
scene,
objectName,
missingBehaviors,
}: {|
project: gdProject,
scene: gdLayout,
objectName: string,
missingBehaviors: Array<AiGeneratedEventMissingObjectBehavior>,
|}) => {
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
project,
scene
);
const objectOrGroupBehaviorNames = projectScopedContainers
.getObjectsContainersList()
.getBehaviorsOfObject(objectName, true)
.toJSArray();
const addBehaviorToObject = (
object: gdObject,
behaviorName: string,
behaviorType: string
) => {
if (object.hasBehaviorNamed(behaviorName)) {
return;
}
gd.WholeProjectRefactorer.addBehaviorAndRequiredBehaviors(
project,
object,
behaviorType,
behaviorName
);
};
const addBehaviorToObjectGroup = (
group: gdObjectGroup,
behaviorName: string,
behaviorType: string
) => {
const objectNames = group.getAllObjectsNames().toJSArray();
objectNames.forEach(objectName => {
if (scene.getObjects().hasObjectNamed(objectName)) {
const object = scene.getObjects().getObject(objectName);
addBehaviorToObject(object, behaviorName, behaviorType);
} else if (project.getObjects().hasObjectNamed(objectName)) {
const object = project.getObjects().getObject(objectName);
addBehaviorToObject(object, behaviorName, behaviorType);
}
});
};
missingBehaviors.forEach(missingBehavior => {
if (objectOrGroupBehaviorNames.includes(missingBehavior.name)) {
// This behavior is already present, no need to add it.
return;
}
const behaviorMetadata = gd.MetadataProvider.getBehaviorMetadata(
project.getCurrentPlatform(),
missingBehavior.type
);
if (gd.MetadataProvider.isBadBehaviorMetadata(behaviorMetadata)) {
console.warn(
`Unknown behavior type: "${missingBehavior.type}". Skipping.`
);
return;
}
if (isBehaviorDefaultCapability(behaviorMetadata)) {
console.warn(
`Behavior "${missingBehavior.name}" of type "${
missingBehavior.type
}" is a default capability and cannot be added to object "${objectName}".`
);
return;
}
if (scene.getObjects().hasObjectNamed(objectName)) {
const object = scene.getObjects().getObject(objectName);
addBehaviorToObject(object, missingBehavior.name, missingBehavior.type);
} else if (
scene
.getObjects()
.getObjectGroups()
.has(objectName)
) {
const group = scene
.getObjects()
.getObjectGroups()
.get(objectName);
addBehaviorToObjectGroup(
group,
missingBehavior.name,
missingBehavior.type
);
} else if (project.getObjects().hasObjectNamed(objectName)) {
const object = project.getObjects().getObject(objectName);
addBehaviorToObject(object, missingBehavior.name, missingBehavior.type);
} else if (
project
.getObjects()
.getObjectGroups()
.has(objectName)
) {
const group = project
.getObjects()
.getObjectGroups()
.get(objectName);
addBehaviorToObjectGroup(
group,
missingBehavior.name,
missingBehavior.type
);
}
});
scene.updateBehaviorsSharedData(project);
};

View File

@@ -53,7 +53,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -96,7 +95,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -133,7 +131,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -168,7 +165,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -204,7 +200,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -239,7 +234,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -278,7 +272,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
{
operationName: 'insert_before_event',
@@ -290,7 +283,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(
@@ -329,7 +321,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
@@ -365,7 +356,6 @@ describe('applyEventsChanges', () => {
extensionNames: [],
undeclaredVariables: [],
undeclaredObjectVariables: {},
missingObjectBehaviors: {},
},
];
applyEventsChanges(

View File

@@ -10,7 +10,6 @@ import {
type AssetSearchAndInstallOptions,
type AssetSearchAndInstallResult,
type SceneEventsOutsideEditorChanges,
type InstancesOutsideEditorChanges,
} from '.';
export type EditorFunctionCallResult =
@@ -40,9 +39,6 @@ export type ProcessEditorFunctionCallsOptions = {|
onSceneEventsModifiedOutsideEditor: (
changes: SceneEventsOutsideEditorChanges
) => void,
onInstancesModifiedOutsideEditor: (
changes: InstancesOutsideEditorChanges
) => void,
ensureExtensionInstalled: (options: {|
extensionName: string,
|}) => Promise<void>,
@@ -57,7 +53,6 @@ export const processEditorFunctionCalls = async ({
editorCallbacks,
generateEvents,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
ignore,
ensureExtensionInstalled,
searchAndInstallAsset,
@@ -141,7 +136,6 @@ export const processEditorFunctionCalls = async ({
args,
generateEvents,
onSceneEventsModifiedOutsideEditor,
onInstancesModifiedOutsideEditor,
ensureExtensionInstalled,
searchAndInstallAsset,
}

View File

@@ -32,12 +32,6 @@ export type ExpressionSummary = {|
relevantForSceneEvents?: boolean,
|};
export type PropertySummary = {|
name: string,
description: string,
type: string,
|};
export type ObjectSummary = {|
name: string,
fullName: string,
@@ -57,17 +51,6 @@ export type BehaviorSummary = {|
expressions: Array<ExpressionSummary>,
|};
export type EffectSummary = {|
name: string,
fullName: string,
description: string,
notWorkingForObjects: boolean,
onlyWorkingFor2D: boolean,
onlyWorkingFor3D: boolean,
unique: boolean,
properties: Array<PropertySummary>,
|};
export type ExtensionSummary = {|
extensionName: string,
extensionFullName: string,
@@ -77,7 +60,6 @@ export type ExtensionSummary = {|
freeExpressions: Array<ExpressionSummary>,
objects: { [string]: ObjectSummary },
behaviors: { [string]: BehaviorSummary },
effects: { [string]: EffectSummary },
|};
const normalizeType = (parameterType: string) => {
@@ -120,29 +102,6 @@ const getParameterSummary = (
return parameterSummary;
};
const getPropertySummary = (
propertyName: string,
property: gdPropertyDescriptor
) => {
return {
name: propertyName,
description: property.getDescription(),
type: property.getType(),
};
};
const getPropertiesSummary = (
propertiesMetadata: gdMapStringPropertyDescriptor
) => {
return propertiesMetadata
.keys()
.toJSArray()
.map(propertyName => {
const property = propertiesMetadata.get(propertyName);
return getPropertySummary(propertyName, property);
});
};
export const buildExtensionSummary = ({
gd,
extension,
@@ -152,7 +111,6 @@ export const buildExtensionSummary = ({
}): ExtensionSummary => {
const objects: { [string]: ObjectSummary } = {};
const behaviors: { [string]: BehaviorSummary } = {};
const effects: { [string]: EffectSummary } = {};
const generateInstructionsSummaries = ({
instructionsMetadata,
@@ -296,27 +254,6 @@ export const buildExtensionSummary = ({
behaviors[behaviorType] = behaviorSummary;
});
extension
.getExtensionEffectTypes()
.toJSArray()
.forEach(effectType => {
const effectMetadata = extension.getEffectMetadata(effectType);
if (gd.MetadataProvider.isBadEffectMetadata(effectMetadata)) {
return;
}
const effectSummary: EffectSummary = {
name: effectMetadata.getType(),
fullName: effectMetadata.getFullName(),
description: effectMetadata.getDescription(),
notWorkingForObjects: effectMetadata.isMarkedAsNotWorkingForObjects(),
onlyWorkingFor2D: effectMetadata.isMarkedAsOnlyWorkingFor2D(),
onlyWorkingFor3D: effectMetadata.isMarkedAsOnlyWorkingFor3D(),
unique: effectMetadata.isMarkedAsUnique(),
properties: getPropertiesSummary(effectMetadata.getProperties()),
};
effects[effectType] = effectSummary;
});
return {
extensionName: extension.getName(),
@@ -338,6 +275,5 @@ export const buildExtensionSummary = ({
],
objects,
behaviors,
effects,
};
};

View File

@@ -894,7 +894,6 @@ describe('SimplifiedProject', () => {
},
},
"description": "A fake extension with a fake behavior containing 2 properties.",
"effects": Object {},
"extensionFullName": "Fake extension with a fake behavior",
"extensionName": "FakeBehavior",
"freeActions": Array [],

File diff suppressed because it is too large Load Diff

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