mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
27 Commits
feat/agent
...
v5.5.232
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a4c2778b8d | ||
![]() |
f26e56c3bf | ||
![]() |
f5f9944fc4 | ||
![]() |
9467caf1e9 | ||
![]() |
00376f39d5 | ||
![]() |
40b6a34dc5 | ||
![]() |
17d2b8c2c2 | ||
![]() |
935af42d23 | ||
![]() |
d4a8d468cb | ||
![]() |
b16099aee0 | ||
![]() |
c17b918a43 | ||
![]() |
d58e8c7ef9 | ||
![]() |
ddd6b6e3a8 | ||
![]() |
e629c132ea | ||
![]() |
b80e03f153 | ||
![]() |
11e36ff3f1 | ||
![]() |
22de356413 | ||
![]() |
caefa04fbe | ||
![]() |
cf2e7d67d7 | ||
![]() |
685e444b2d | ||
![]() |
a9c1045afd | ||
![]() |
24e0d37583 | ||
![]() |
d44997d372 | ||
![]() |
062aa888f8 | ||
![]() |
de4c2ae4ad | ||
![]() |
29ad7308c3 | ||
![]() |
19b21c280e |
@@ -18,13 +18,13 @@ jobs:
|
||||
# Build the **entire** app for macOS (including the GDevelop.js library).
|
||||
build-macos:
|
||||
macos:
|
||||
xcode: 14.2.0
|
||||
resource_class: macos.m1.large.gen1
|
||||
xcode: 16.4.0
|
||||
resource_class: m4pro.medium
|
||||
steps:
|
||||
- checkout
|
||||
# Install Rosetta for AWS CLI and disable TSO to speed up S3 uploads (https://support.circleci.com/hc/en-us/articles/19334402064027-Troubleshooting-slow-uploads-to-S3-for-jobs-using-an-m1-macOS-resource-class)
|
||||
- macos/install-rosetta
|
||||
- run: sudo sysctl net.inet.tcp.tso=0
|
||||
# - run: sudo sysctl net.inet.tcp.tso=0
|
||||
|
||||
# Install a recent version of npm to workaround a notarization issue because of a symlink made by npm: https://github.com/electron-userland/electron-builder/issues/7755
|
||||
# Node.js v20.14.0 comes with npm v10.7.0.
|
||||
@@ -88,13 +88,35 @@ jobs:
|
||||
- store_artifacts:
|
||||
path: newIDE/electron-app/dist
|
||||
|
||||
|
||||
# Upload artifacts (AWS)
|
||||
- run:
|
||||
name: Deploy to S3 (specific commit)
|
||||
command: export PATH=~/.local/bin:$PATH && aws s3 sync newIDE/electron-app/dist s3://gdevelop-releases/$(git rev-parse --abbrev-ref HEAD)/commit/$(git rev-parse HEAD)/
|
||||
command: |
|
||||
export PATH=~/.local/bin:$PATH
|
||||
for i in 1 2 3 4 5 6 7; do
|
||||
aws s3 sync newIDE/electron-app/dist s3://gdevelop-releases/$(git rev-parse --abbrev-ref HEAD)/commit/$(git rev-parse HEAD)/ && break
|
||||
echo "Retry $i failed... retrying in 10 seconds"
|
||||
sleep 10
|
||||
done
|
||||
if [ $i -eq 7 ]; then
|
||||
echo "All retries for deployment failed!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- run:
|
||||
name: Deploy to S3 (latest)
|
||||
command: export PATH=~/.local/bin:$PATH && aws s3 sync newIDE/electron-app/dist s3://gdevelop-releases/$(git rev-parse --abbrev-ref HEAD)/latest/
|
||||
command: |
|
||||
export PATH=~/.local/bin:$PATH
|
||||
for i in 1 2 3 4 5 6 7; do
|
||||
aws s3 sync newIDE/electron-app/dist s3://gdevelop-releases/$(git rev-parse --abbrev-ref HEAD)/latest/ && break
|
||||
echo "Retry $i failed... retrying in 10 seconds"
|
||||
sleep 10
|
||||
done
|
||||
if [ $i -eq 7 ]; then
|
||||
echo "All retries for deployment failed!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the app for Linux (using a pre-built GDevelop.js library).
|
||||
build-linux:
|
||||
@@ -368,7 +390,7 @@ jobs:
|
||||
npm -v
|
||||
|
||||
Remove-Item package-lock.json
|
||||
|
||||
|
||||
$Env:REQUIRES_EXACT_LIBGD_JS_VERSION = "true"
|
||||
|
||||
npm install
|
||||
@@ -425,10 +447,10 @@ jobs:
|
||||
|
||||
$Env:GD_SIGNTOOL_THUMBPRINT = ''
|
||||
|
||||
$Env:GD_SIGNTOOL_SUBJECT_NAME = ''
|
||||
|
||||
$Env:GD_SIGNTOOL_SUBJECT_NAME = ''
|
||||
|
||||
$Env:CSC_LINK = ''
|
||||
|
||||
|
||||
$Env:CSC_KEY_PASSWORD = ''
|
||||
|
||||
node scripts/build.js --skip-app-build --win appx --publish=never
|
||||
@@ -445,16 +467,16 @@ jobs:
|
||||
name: Install AWS CLI
|
||||
command: |
|
||||
# Install the CLI for the current user
|
||||
|
||||
|
||||
pip install --quiet --upgrade --user awscli
|
||||
|
||||
|
||||
# Add the user-Scripts dir to PATH for this step and the next.
|
||||
|
||||
|
||||
$binDir = (python -m site --user-base) + "\Scripts"
|
||||
$Env:Path += ";$binDir"
|
||||
|
||||
# Sanity check:
|
||||
aws --version
|
||||
aws --version
|
||||
|
||||
# Upload artifacts (S3)
|
||||
- run:
|
||||
|
@@ -61,10 +61,12 @@ void GroupEvent::UnserializeFrom(gd::Project& project,
|
||||
project, events, element.GetChild("events"));
|
||||
|
||||
parameters.clear();
|
||||
gd::SerializerElement& parametersElement = element.GetChild("parameters");
|
||||
parametersElement.ConsiderAsArrayOf("parameters");
|
||||
for (std::size_t i = 0; i < parametersElement.GetChildrenCount(); ++i)
|
||||
parameters.push_back(parametersElement.GetChild(i).GetValue().GetString());
|
||||
if (element.HasChild("parameters")) {
|
||||
gd::SerializerElement& parametersElement = element.GetChild("parameters");
|
||||
parametersElement.ConsiderAsArrayOf("parameters");
|
||||
for (std::size_t i = 0; i < parametersElement.GetChildrenCount(); ++i)
|
||||
parameters.push_back(parametersElement.GetChild(i).GetValue().GetString());
|
||||
}
|
||||
}
|
||||
|
||||
void GroupEvent::SetBackgroundColor(unsigned int colorR_,
|
||||
|
@@ -286,6 +286,20 @@ class GD_CORE_API BaseEvent {
|
||||
* \brief True if the event should be folded in the events editor.
|
||||
*/
|
||||
bool IsFolded() const { return folded; }
|
||||
|
||||
/**
|
||||
* \brief Set the AI generated event ID.
|
||||
*/
|
||||
void SetAiGeneratedEventId(const gd::String& aiGeneratedEventId_) {
|
||||
aiGeneratedEventId = aiGeneratedEventId_;
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief Get the AI generated event ID.
|
||||
*/
|
||||
const gd::String& GetAiGeneratedEventId() const {
|
||||
return aiGeneratedEventId;
|
||||
}
|
||||
///@}
|
||||
|
||||
std::weak_ptr<gd::BaseEvent>
|
||||
@@ -304,6 +318,7 @@ class GD_CORE_API BaseEvent {
|
||||
bool disabled; ///< True if the event is disabled and must not be executed
|
||||
gd::String type; ///< Type of the event. Must be assigned at the creation.
|
||||
///< Used for saving the event for instance.
|
||||
gd::String aiGeneratedEventId; ///< When generated by an AI/external tool.
|
||||
|
||||
static gd::EventsList badSubEvents;
|
||||
static gd::VariablesContainer badLocalVariables;
|
||||
|
@@ -221,6 +221,8 @@ void EventsListSerialization::UnserializeEventsFrom(
|
||||
|
||||
event->SetDisabled(eventElem.GetBoolAttribute("disabled", false));
|
||||
event->SetFolded(eventElem.GetBoolAttribute("folded", false));
|
||||
event->SetAiGeneratedEventId(
|
||||
eventElem.GetStringAttribute("aiGeneratedEventId", ""));
|
||||
|
||||
list.InsertEvent(event, list.GetEventsCount());
|
||||
}
|
||||
@@ -236,6 +238,8 @@ void EventsListSerialization::SerializeEventsTo(const EventsList& list,
|
||||
if (event.IsDisabled())
|
||||
eventElem.SetAttribute("disabled", event.IsDisabled());
|
||||
if (event.IsFolded()) eventElem.SetAttribute("folded", event.IsFolded());
|
||||
if (!event.GetAiGeneratedEventId().empty())
|
||||
eventElem.SetAttribute("aiGeneratedEventId", event.GetAiGeneratedEventId());
|
||||
eventElem.AddChild("type").SetValue(event.GetType());
|
||||
|
||||
event.SerializeTo(eventElem);
|
||||
|
@@ -235,7 +235,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
|
||||
|
||||
obj.AddAction("SetAngle",
|
||||
_("Angle"),
|
||||
_("Change the angle of rotation of an object (in degrees)."),
|
||||
_("Change the angle of rotation of an object (in degrees). For "
|
||||
"3D objects, this is the rotation around the Z axis."),
|
||||
_("the angle"),
|
||||
_("Angle"),
|
||||
"res/actions/direction24_black.png",
|
||||
@@ -250,7 +251,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
|
||||
obj.AddAction("Rotate",
|
||||
_("Rotate"),
|
||||
_("Rotate an object, clockwise if the speed is positive, "
|
||||
"counterclockwise otherwise."),
|
||||
"counterclockwise otherwise. For 3D objects, this is the "
|
||||
"rotation around the Z axis."),
|
||||
_("Rotate _PARAM0_ at speed _PARAM1_ deg/second"),
|
||||
_("Angle"),
|
||||
"res/actions/rotate24_black.png",
|
||||
@@ -634,7 +636,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
|
||||
|
||||
obj.AddCondition("Angle",
|
||||
_("Angle"),
|
||||
_("Compare the angle of the specified object."),
|
||||
_("Compare the angle, in degrees, of the specified object. "
|
||||
"For 3D objects, this is the angle around the Z axis."),
|
||||
_("the angle (in degrees)"),
|
||||
_("Angle"),
|
||||
"res/conditions/direction24_black.png",
|
||||
@@ -1268,7 +1271,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
|
||||
|
||||
obj.AddExpression("Angle",
|
||||
_("Angle"),
|
||||
_("Current angle, in degrees, of the object"),
|
||||
_("Current angle, in degrees, of the object. For 3D "
|
||||
"objects, this is the angle around the Z axis."),
|
||||
_("Angle"),
|
||||
"res/actions/direction_black.png")
|
||||
.AddParameter("object", _("Object"));
|
||||
|
@@ -250,25 +250,28 @@ void CustomObjectConfiguration::ExposeResources(gd::ArbitraryResourceWorker& wor
|
||||
}
|
||||
const auto &eventsBasedObject = project->GetEventsBasedObject(GetType());
|
||||
|
||||
if (isMarkedAsOverridingEventsBasedObjectChildrenConfiguration) {
|
||||
if (IsForcedToOverrideEventsBasedObjectChildrenConfiguration()) {
|
||||
for (auto &childObject : eventsBasedObject.GetObjects().GetObjects()) {
|
||||
auto &configuration = GetChildObjectConfiguration(childObject->GetName());
|
||||
configuration.ExposeResources(worker);
|
||||
}
|
||||
}
|
||||
else if (eventsBasedObject.GetVariants().HasVariantNamed(variantName)) {
|
||||
for (auto &childObject : eventsBasedObject.GetVariants()
|
||||
.GetVariant(variantName)
|
||||
.GetObjects()
|
||||
.GetObjects()) {
|
||||
childObject->GetConfiguration().ExposeResources(worker);
|
||||
}
|
||||
} else if (isMarkedAsOverridingEventsBasedObjectChildrenConfiguration) {
|
||||
for (auto &childObject : eventsBasedObject.GetObjects().GetObjects()) {
|
||||
auto &configuration = GetChildObjectConfiguration(childObject->GetName());
|
||||
configuration.ExposeResources(worker);
|
||||
}
|
||||
} else {
|
||||
if (variantName.empty() ||
|
||||
!eventsBasedObject.GetVariants().HasVariantNamed(variantName)) {
|
||||
for (auto &childObject :
|
||||
eventsBasedObject.GetDefaultVariant().GetObjects().GetObjects()) {
|
||||
childObject->GetConfiguration().ExposeResources(worker);
|
||||
}
|
||||
} else {
|
||||
for (auto &childObject : eventsBasedObject.GetVariants()
|
||||
.GetVariant(variantName)
|
||||
.GetObjects()
|
||||
.GetObjects()) {
|
||||
childObject->GetConfiguration().ExposeResources(worker);
|
||||
}
|
||||
for (auto &childObject :
|
||||
eventsBasedObject.GetDefaultVariant().GetObjects().GetObjects()) {
|
||||
childObject->GetConfiguration().ExposeResources(worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -78,6 +78,15 @@ public:
|
||||
variantName = variantName_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy events-based objects don't have any instance in their default
|
||||
* variant since there wasn't a graphical editor at the time. In this case,
|
||||
* the editor doesn't allow to choose a variant, but a variant may have stayed
|
||||
* after a user rolled back the extension. This variant must be ignored.
|
||||
*
|
||||
* @return true when its events-based object doesn't have any initial
|
||||
* instance.
|
||||
*/
|
||||
bool IsForcedToOverrideEventsBasedObjectChildrenConfiguration() const;
|
||||
|
||||
bool IsMarkedAsOverridingEventsBasedObjectChildrenConfiguration() const {
|
||||
|
@@ -365,6 +365,8 @@ class GD_CORE_API InitialInstance {
|
||||
* the same initial instance between serialization.
|
||||
*/
|
||||
InitialInstance& ResetPersistentUuid();
|
||||
|
||||
const gd::String& GetPersistentUuid() const { return persistentUuid; }
|
||||
///@}
|
||||
|
||||
private:
|
||||
|
@@ -81,6 +81,11 @@ class GD_CORE_API ObjectsContainersList {
|
||||
/**
|
||||
* \brief Return the container of the variables for the specified object or
|
||||
* group of objects.
|
||||
*
|
||||
* \warning In most cases, prefer to use other methods to access variables or use
|
||||
* ObjectVariableHelper::MergeVariableContainers if you know you're dealing with a group.
|
||||
* This is because the variables container of an object group does not exist and the one from
|
||||
* first object of the group will be returned.
|
||||
*/
|
||||
const gd::VariablesContainer* GetObjectOrGroupVariablesContainer(
|
||||
const gd::String& objectOrGroupName) const;
|
||||
|
@@ -21,14 +21,19 @@ void PropertyDescriptor::SerializeTo(SerializerElement& element) const {
|
||||
element.AddChild("unit").SetStringValue(measurementUnit.GetName());
|
||||
}
|
||||
element.AddChild("label").SetStringValue(label);
|
||||
element.AddChild("description").SetStringValue(description);
|
||||
element.AddChild("group").SetStringValue(group);
|
||||
SerializerElement& extraInformationElement =
|
||||
element.AddChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (const gd::String& information : extraInformation) {
|
||||
extraInformationElement.AddChild("").SetStringValue(information);
|
||||
if (!description.empty())
|
||||
element.AddChild("description").SetStringValue(description);
|
||||
if (!group.empty()) element.AddChild("group").SetStringValue(group);
|
||||
|
||||
if (!extraInformation.empty()) {
|
||||
SerializerElement& extraInformationElement =
|
||||
element.AddChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (const gd::String& information : extraInformation) {
|
||||
extraInformationElement.AddChild("").SetStringValue(information);
|
||||
}
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
element.AddChild("hidden").SetBoolValue(hidden);
|
||||
}
|
||||
@@ -59,16 +64,21 @@ void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) {
|
||||
: gd::MeasurementUnit::GetUndefined();
|
||||
}
|
||||
label = element.GetChild("label").GetStringValue();
|
||||
description = element.GetChild("description").GetStringValue();
|
||||
group = element.GetChild("group").GetStringValue();
|
||||
description = element.HasChild("description")
|
||||
? element.GetChild("description").GetStringValue()
|
||||
: "";
|
||||
group = element.HasChild("group") ? element.GetChild("group").GetStringValue()
|
||||
: "";
|
||||
|
||||
extraInformation.clear();
|
||||
const SerializerElement& extraInformationElement =
|
||||
element.GetChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (std::size_t i = 0; i < extraInformationElement.GetChildrenCount(); ++i)
|
||||
extraInformation.push_back(
|
||||
extraInformationElement.GetChild(i).GetStringValue());
|
||||
if (element.HasChild("extraInformation")) {
|
||||
const SerializerElement& extraInformationElement =
|
||||
element.GetChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (std::size_t i = 0; i < extraInformationElement.GetChildrenCount(); ++i)
|
||||
extraInformation.push_back(
|
||||
extraInformationElement.GetChild(i).GetStringValue());
|
||||
}
|
||||
|
||||
hidden = element.HasChild("hidden")
|
||||
? element.GetChild("hidden").GetBoolValue()
|
||||
|
@@ -34,7 +34,6 @@ namespace gdjs {
|
||||
objectData: gdjs.Object3DData & gdjs.CustomObjectConfiguration
|
||||
) {
|
||||
super(parent, objectData);
|
||||
this._renderer.reinitialize(this, parent);
|
||||
}
|
||||
|
||||
protected override _createRender() {
|
||||
|
@@ -44,10 +44,7 @@ namespace gdjs {
|
||||
) {
|
||||
this._object = object;
|
||||
this._isContainerDirty = true;
|
||||
const layer = parent.getLayer('');
|
||||
if (layer) {
|
||||
layer.getRenderer().add3DRendererObject(this._threeGroup);
|
||||
}
|
||||
this._threeGroup.clear();
|
||||
}
|
||||
|
||||
_updateThreeGroup() {
|
||||
|
@@ -14,6 +14,7 @@ describe('gdjs.AnchorRuntimeBehavior', () => {
|
||||
effects: [],
|
||||
content: {},
|
||||
childrenContent: {},
|
||||
isInnerAreaFollowingParentSize: false,
|
||||
});
|
||||
runtimeScene.addObject(customObject);
|
||||
customObject.setPosition(500, 250);
|
||||
|
@@ -12,7 +12,7 @@ This project is released under the MIT License.
|
||||
#include "GDCore/Tools/Localization.h"
|
||||
|
||||
void DestroyOutsideBehavior::InitializeContent(gd::SerializerElement& content) {
|
||||
content.SetAttribute("extraBorder", 0);
|
||||
content.SetAttribute("extraBorder", 300);
|
||||
}
|
||||
|
||||
#if defined(GD_IDE_ONLY)
|
||||
|
@@ -35,25 +35,32 @@ void DeclareDraggableBehaviorExtension(gd::PlatformExtension& extension) {
|
||||
std::make_shared<DraggableBehavior>(),
|
||||
std::shared_ptr<gd::BehaviorsSharedData>());
|
||||
|
||||
aut.AddCondition("Dragged",
|
||||
_("Being dragged"),
|
||||
_("Check if the object is being dragged."),
|
||||
_("_PARAM0_ is being dragged"),
|
||||
_("Draggable"),
|
||||
"CppPlatform/Extensions/draggableicon24.png",
|
||||
"CppPlatform/Extensions/draggableicon16.png")
|
||||
aut.AddCondition(
|
||||
"Dragged",
|
||||
_("Being dragged"),
|
||||
_("Check if the object is being dragged. This means the mouse button "
|
||||
"or touch is pressed on it. When the mouse button or touch is "
|
||||
"released, the object is no longer being considered dragged (use "
|
||||
"the condition \"Was just dropped\" to check when the dragging is "
|
||||
"ending)."),
|
||||
_("_PARAM0_ is being dragged"),
|
||||
_("Draggable"),
|
||||
"CppPlatform/Extensions/draggableicon24.png",
|
||||
"CppPlatform/Extensions/draggableicon16.png")
|
||||
|
||||
.AddParameter("object", _("Object"))
|
||||
.AddParameter("behavior", _("Behavior"), "Draggable")
|
||||
.SetFunctionName("IsDragged");
|
||||
|
||||
aut.AddCondition("Dropped",
|
||||
_("Was just dropped"),
|
||||
_("Check if the object was just dropped after being dragged."),
|
||||
_("_PARAM0_ was just dropped"),
|
||||
_("Draggable"),
|
||||
"CppPlatform/Extensions/draggableicon24.png",
|
||||
"CppPlatform/Extensions/draggableicon16.png")
|
||||
aut.AddCondition(
|
||||
"Dropped",
|
||||
_("Was just dropped"),
|
||||
_("Check if the object was just dropped after being dragged (the "
|
||||
"mouse button or touch was just released this frame)."),
|
||||
_("_PARAM0_ was just dropped"),
|
||||
_("Draggable"),
|
||||
"CppPlatform/Extensions/draggableicon24.png",
|
||||
"CppPlatform/Extensions/draggableicon16.png")
|
||||
|
||||
.AddParameter("object", _("Object"))
|
||||
.AddParameter("behavior", _("Behavior"), "Draggable")
|
||||
|
@@ -109,11 +109,11 @@ PlatformerObjectBehavior::GetProperties(
|
||||
.SetValue(
|
||||
gd::String::From(behaviorContent.GetDoubleAttribute("maxSpeed")));
|
||||
properties["IgnoreDefaultControls"]
|
||||
.SetLabel(_("Default controls"))
|
||||
.SetLabel(_("Disable default keyboard controls"))
|
||||
.SetQuickCustomizationVisibility(gd::QuickCustomization::Hidden)
|
||||
.SetValue(behaviorContent.GetBoolAttribute("ignoreDefaultControls")
|
||||
? "false"
|
||||
: "true")
|
||||
? "true"
|
||||
: "false")
|
||||
.SetType("Boolean");
|
||||
properties["SlopeMaxAngle"]
|
||||
.SetLabel(_("Slope max. angle"))
|
||||
@@ -190,7 +190,7 @@ bool PlatformerObjectBehavior::UpdateProperty(
|
||||
const gd::String& name,
|
||||
const gd::String& value) {
|
||||
if (name == "IgnoreDefaultControls")
|
||||
behaviorContent.SetAttribute("ignoreDefaultControls", (value == "0"));
|
||||
behaviorContent.SetAttribute("ignoreDefaultControls", (value == "1"));
|
||||
else if (name == "CanGrabPlatforms")
|
||||
behaviorContent.SetAttribute("canGrabPlatforms", (value == "1"));
|
||||
else if (name == "CanGrabWithoutMoving")
|
||||
|
@@ -90,10 +90,11 @@ TopDownMovementBehavior::GetProperties(
|
||||
.SetValue(
|
||||
gd::String::From(behaviorContent.GetDoubleAttribute("angleOffset")));
|
||||
properties["IgnoreDefaultControls"]
|
||||
.SetLabel(_("Default controls"))
|
||||
.SetLabel(_("Disable default keyboard controls"))
|
||||
.SetQuickCustomizationVisibility(gd::QuickCustomization::Hidden)
|
||||
.SetValue(behaviorContent.GetBoolAttribute("ignoreDefaultControls")
|
||||
? "false"
|
||||
: "true")
|
||||
? "true"
|
||||
: "false")
|
||||
.SetType("Boolean");
|
||||
properties["UseLegacyTurnBack"]
|
||||
.SetLabel(_("Only use acceleration to turn back "
|
||||
@@ -155,7 +156,7 @@ bool TopDownMovementBehavior::UpdateProperty(
|
||||
const gd::String& name,
|
||||
const gd::String& value) {
|
||||
if (name == "IgnoreDefaultControls") {
|
||||
behaviorContent.SetAttribute("ignoreDefaultControls", (value == "0"));
|
||||
behaviorContent.SetAttribute("ignoreDefaultControls", (value == "1"));
|
||||
return true;
|
||||
}
|
||||
if (name == "AllowDiagonals") {
|
||||
|
@@ -14,6 +14,7 @@ namespace gdjs {
|
||||
animatable?: SpriteAnimationData[];
|
||||
variant: string;
|
||||
childrenContent: { [objectName: string]: ObjectConfiguration & any };
|
||||
isInnerAreaFollowingParentSize: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -111,9 +112,19 @@ namespace gdjs {
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
// Legacy events-based objects don't have any instance in their default
|
||||
// variant since there wasn't a graphical editor at the time.
|
||||
// In this case, the editor doesn't allow to choose a variant, but a
|
||||
// variant may have stayed after a user rolled back the extension.
|
||||
// This variant must be ignored to match what the editor shows.
|
||||
const isForcedToOverrideEventsBasedObjectChildrenConfiguration =
|
||||
eventsBasedObjectData.defaultVariant.instances.length == 0;
|
||||
let usedVariantData: EventsBasedObjectVariantData =
|
||||
eventsBasedObjectData.defaultVariant;
|
||||
if (customObjectData.variant) {
|
||||
if (
|
||||
customObjectData.variant &&
|
||||
!isForcedToOverrideEventsBasedObjectChildrenConfiguration
|
||||
) {
|
||||
for (
|
||||
let variantIndex = 0;
|
||||
variantIndex < eventsBasedObjectData.variants.length;
|
||||
@@ -154,10 +165,12 @@ namespace gdjs {
|
||||
override reinitialize(objectData: ObjectData & CustomObjectConfiguration) {
|
||||
super.reinitialize(objectData);
|
||||
|
||||
this._initializeFromObjectData(objectData);
|
||||
this._reinitializeRenderer();
|
||||
this._initializeFromObjectData(objectData);
|
||||
|
||||
// The generated code calls the onCreated super implementation at the end.
|
||||
// When changing the variant, the instance is like a new instance.
|
||||
// We call again `onCreated` at the end, like done by the constructor
|
||||
// the first time it's created.
|
||||
this.onCreated();
|
||||
}
|
||||
|
||||
@@ -172,6 +185,34 @@ namespace gdjs {
|
||||
newObjectData.animatable || []
|
||||
);
|
||||
}
|
||||
if (oldObjectData.variant !== newObjectData.variant) {
|
||||
const width = this.getWidth();
|
||||
const height = this.getHeight();
|
||||
const hasInnerAreaChanged =
|
||||
oldObjectData.isInnerAreaFollowingParentSize &&
|
||||
this._instanceContainer._initialInnerArea &&
|
||||
this._innerArea &&
|
||||
(this._instanceContainer._initialInnerArea.min[0] !==
|
||||
this._innerArea.min[0] ||
|
||||
this._instanceContainer._initialInnerArea.min[1] !==
|
||||
this._innerArea.min[1] ||
|
||||
this._instanceContainer._initialInnerArea.max[0] !==
|
||||
this._innerArea.max[0] ||
|
||||
this._instanceContainer._initialInnerArea.max[1] !==
|
||||
this._innerArea.max[1]);
|
||||
|
||||
this._reinitializeRenderer();
|
||||
this._initializeFromObjectData(newObjectData);
|
||||
|
||||
// The generated code calls the onCreated super implementation at the end.
|
||||
this.onCreated();
|
||||
|
||||
// Keep the custom size
|
||||
if (hasInnerAreaChanged) {
|
||||
this.setWidth(width);
|
||||
this.setHeight(height);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@@ -8,7 +8,6 @@ namespace gdjs {
|
||||
objectData: ObjectData & CustomObjectConfiguration
|
||||
) {
|
||||
super(parent, objectData);
|
||||
this.getRenderer().reinitialize(this, parent);
|
||||
}
|
||||
|
||||
protected override _createRender(): gdjs.CustomRuntimeObject2DRenderer {
|
||||
|
@@ -24,7 +24,7 @@ namespace gdjs {
|
||||
*
|
||||
* @see gdjs.CustomRuntimeObject._innerArea
|
||||
**/
|
||||
private _initialInnerArea: {
|
||||
_initialInnerArea: {
|
||||
min: [float, float, float];
|
||||
max: [float, float, float];
|
||||
} | null = null;
|
||||
@@ -47,6 +47,9 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
addLayer(layerData: LayerData) {
|
||||
if (this._layers.containsKey(layerData.name)) {
|
||||
return;
|
||||
}
|
||||
const layer = new gdjs.RuntimeCustomObjectLayer(layerData, this);
|
||||
this._layers.put(layerData.name, layer);
|
||||
this._orderedLayers.push(layer);
|
||||
@@ -71,6 +74,10 @@ namespace gdjs {
|
||||
this.onDestroyFromScene(this._parent);
|
||||
}
|
||||
|
||||
const isForcedToOverrideEventsBasedObjectChildrenConfiguration =
|
||||
!eventsBasedObjectVariantData.name &&
|
||||
eventsBasedObjectVariantData.instances.length == 0;
|
||||
|
||||
this._setOriginalInnerArea(eventsBasedObjectVariantData);
|
||||
|
||||
// Registering objects
|
||||
@@ -83,7 +90,8 @@ namespace gdjs {
|
||||
// The children configuration override only applies to the default variant.
|
||||
if (
|
||||
customObjectData.childrenContent &&
|
||||
!eventsBasedObjectVariantData.name
|
||||
(!eventsBasedObjectVariantData.name ||
|
||||
isForcedToOverrideEventsBasedObjectChildrenConfiguration)
|
||||
) {
|
||||
this.registerObject({
|
||||
...childObjectData,
|
||||
|
@@ -811,6 +811,8 @@ namespace gdjs {
|
||||
this._objectsCtor = new Hashtable();
|
||||
this._allInstancesList = [];
|
||||
this._instancesRemoved = [];
|
||||
this._layersCameraCoordinates = {};
|
||||
this._initialBehaviorSharedData = new Hashtable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -222,7 +222,9 @@ namespace gdjs {
|
||||
kind: 'fatal',
|
||||
message:
|
||||
'Unexpected error happened while hot-reloading: ' +
|
||||
error.message,
|
||||
error.message +
|
||||
'\n' +
|
||||
error.stack,
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -472,13 +474,24 @@ namespace gdjs {
|
||||
newExternalLayoutData.associatedLayout
|
||||
);
|
||||
|
||||
const oldObjectDataList =
|
||||
HotReloader.resolveCustomObjectConfigurations(
|
||||
oldProjectData,
|
||||
oldLayoutData ? oldLayoutData.objects : []
|
||||
);
|
||||
const newObjectDataList =
|
||||
HotReloader.resolveCustomObjectConfigurations(
|
||||
newProjectData,
|
||||
newLayoutData ? newLayoutData.objects : []
|
||||
);
|
||||
|
||||
sceneStack._stack.forEach((runtimeScene) => {
|
||||
this._hotReloadRuntimeSceneInstances(
|
||||
oldProjectData,
|
||||
newProjectData,
|
||||
changedRuntimeBehaviors,
|
||||
oldLayoutData ? oldLayoutData.objects : [],
|
||||
newLayoutData ? newLayoutData.objects : [],
|
||||
oldObjectDataList,
|
||||
newObjectDataList,
|
||||
oldExternalLayoutData.instances,
|
||||
newExternalLayoutData.instances,
|
||||
runtimeScene
|
||||
|
@@ -50,12 +50,7 @@ namespace gdjs {
|
||||
) {
|
||||
this._object = object;
|
||||
this._isContainerDirty = true;
|
||||
const layer = parent.getLayer('');
|
||||
if (layer) {
|
||||
layer
|
||||
.getRenderer()
|
||||
.addRendererObject(this._pixiContainer, object.getZOrder());
|
||||
}
|
||||
this._pixiContainer.removeChildren();
|
||||
}
|
||||
|
||||
getRendererObject() {
|
||||
|
2
GDJS/Runtime/types/project-data.d.ts
vendored
2
GDJS/Runtime/types/project-data.d.ts
vendored
@@ -229,7 +229,7 @@ declare interface EventsBasedObjectVariantData extends InstanceContainerData {
|
||||
/**
|
||||
* A value shared by every object instances.
|
||||
*
|
||||
* @see gdjs.CustomRuntimeObjectInstanceContainer._originalInnerArea
|
||||
* @see gdjs.CustomRuntimeObjectInstanceContainer._initialInnerArea
|
||||
**/
|
||||
_initialInnerArea: {
|
||||
min: [float, float, float];
|
||||
|
@@ -16,6 +16,7 @@ describe('gdjs.CustomRuntimeObject', function () {
|
||||
name: 'MyCustomObject',
|
||||
type: 'MyExtension::MyEventsBasedObject',
|
||||
variant: '',
|
||||
isInnerAreaFollowingParentSize: false,
|
||||
variables: [],
|
||||
behaviors: [],
|
||||
effects: [],
|
||||
|
@@ -100,6 +100,7 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => {
|
||||
effects: [],
|
||||
content: {},
|
||||
childrenContent: {},
|
||||
isInnerAreaFollowingParentSize: false,
|
||||
};
|
||||
|
||||
/** @type {LayerData} */
|
||||
|
@@ -1405,6 +1405,7 @@ interface InitialInstance {
|
||||
double GetCustomDepth();
|
||||
|
||||
[Ref] InitialInstance ResetPersistentUuid();
|
||||
[Const, Ref] DOMString GetPersistentUuid();
|
||||
|
||||
void UpdateCustomProperty(
|
||||
[Const] DOMString name,
|
||||
@@ -2344,6 +2345,9 @@ interface BaseEvent {
|
||||
|
||||
void SerializeTo([Ref] SerializerElement element);
|
||||
void UnserializeFrom([Ref] Project project, [Const, Ref] SerializerElement element);
|
||||
|
||||
[Const, Ref] DOMString GetAiGeneratedEventId();
|
||||
void SetAiGeneratedEventId([Const] DOMString aiGeneratedEventId);
|
||||
};
|
||||
|
||||
interface StandardEvent {
|
||||
|
@@ -140,6 +140,9 @@ describe('libGD.js', function () {
|
||||
expect(gd.Project.getSafeName('官话 name')).toBe('官话_name');
|
||||
expect(gd.Project.getSafeName('')).toBe('Unnamed');
|
||||
expect(gd.Project.getSafeName('9')).toBe('_9');
|
||||
expect(gd.Project.getSafeName('ExplosionParticles3D')).toBe(
|
||||
'ExplosionParticles3D'
|
||||
);
|
||||
});
|
||||
|
||||
it('should have a list of extensions', function () {
|
||||
|
3
GDevelop.js/types.d.ts
vendored
3
GDevelop.js/types.d.ts
vendored
@@ -1167,6 +1167,7 @@ export class InitialInstance extends EmscriptenObject {
|
||||
setCustomDepth(depth: number): void;
|
||||
getCustomDepth(): number;
|
||||
resetPersistentUuid(): InitialInstance;
|
||||
getPersistentUuid(): string;
|
||||
updateCustomProperty(name: string, value: string, globalObjectsContainer: ObjectsContainer, objectsContainer: ObjectsContainer): void;
|
||||
getCustomProperties(globalObjectsContainer: ObjectsContainer, objectsContainer: ObjectsContainer): MapStringPropertyDescriptor;
|
||||
getRawDoubleProperty(name: string): number;
|
||||
@@ -1782,6 +1783,8 @@ export class BaseEvent extends EmscriptenObject {
|
||||
setFolded(folded: boolean): void;
|
||||
serializeTo(element: SerializerElement): void;
|
||||
unserializeFrom(project: Project, element: SerializerElement): void;
|
||||
getAiGeneratedEventId(): string;
|
||||
setAiGeneratedEventId(aiGeneratedEventId: string): void;
|
||||
}
|
||||
|
||||
export class StandardEvent extends BaseEvent {
|
||||
|
@@ -17,6 +17,8 @@ declare class gdBaseEvent extends gdBaseEvent {
|
||||
setFolded(folded: boolean): void;
|
||||
serializeTo(element: gdSerializerElement): void;
|
||||
unserializeFrom(project: gdProject, element: gdSerializerElement): void;
|
||||
getAiGeneratedEventId(): string;
|
||||
setAiGeneratedEventId(aiGeneratedEventId: string): void;
|
||||
delete(): void;
|
||||
ptr: number;
|
||||
};
|
@@ -44,6 +44,7 @@ declare class gdInitialInstance {
|
||||
setCustomDepth(depth: number): void;
|
||||
getCustomDepth(): number;
|
||||
resetPersistentUuid(): gdInitialInstance;
|
||||
getPersistentUuid(): string;
|
||||
updateCustomProperty(name: string, value: string, globalObjectsContainer: gdObjectsContainer, objectsContainer: gdObjectsContainer): void;
|
||||
getCustomProperties(globalObjectsContainer: gdObjectsContainer, objectsContainer: gdObjectsContainer): gdMapStringPropertyDescriptor;
|
||||
getRawDoubleProperty(name: string): number;
|
||||
|
@@ -7,30 +7,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-bubble-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes content-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
/* Start after the bubble container. */
|
||||
37% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -54,18 +30,21 @@
|
||||
justify-content: center;
|
||||
|
||||
animation: new-chat-appear 0.5s;
|
||||
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.chatBubbleContainer {
|
||||
.aiRequestChatContainer {
|
||||
display: flex;
|
||||
animation: chat-bubble-appear 0.3s;
|
||||
min-width: 0; /** Prevents horizontal overflow */
|
||||
}
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
flex: 1 1 0%;
|
||||
min-height: 0px;
|
||||
|
||||
.chatBubbleContent {
|
||||
display: flex;
|
||||
animation: content-appear 0.8s;
|
||||
min-width: 0; /** Prevents horizontal overflow */
|
||||
margin-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.thinkingText {
|
158
newIDE/app/src/AiGeneration/AiRequestChat/AiRequestUtils.js
Normal file
158
newIDE/app/src/AiGeneration/AiRequestChat/AiRequestUtils.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// @flow
|
||||
import {
|
||||
type AiRequest,
|
||||
type AiRequestMessageAssistantFunctionCall,
|
||||
type AiRequestFunctionCallOutput,
|
||||
} from '../../Utils/GDevelopServices/Generation';
|
||||
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
|
||||
|
||||
export const getFunctionCallToFunctionCallOutputMap = ({
|
||||
aiRequest,
|
||||
}: {|
|
||||
aiRequest: AiRequest,
|
||||
|}): Map<
|
||||
AiRequestMessageAssistantFunctionCall,
|
||||
AiRequestFunctionCallOutput | null
|
||||
> => {
|
||||
// Maps each function call to its corresponding output (or null if no output)
|
||||
const functionCallsToOutputs = new Map<
|
||||
AiRequestMessageAssistantFunctionCall,
|
||||
AiRequestFunctionCallOutput | null
|
||||
>();
|
||||
|
||||
// Track function calls by their call_id to match with outputs
|
||||
const functionCallsByCallId = new Map<
|
||||
string,
|
||||
AiRequestMessageAssistantFunctionCall
|
||||
>();
|
||||
|
||||
// Process messages in a single loop
|
||||
for (let i = 0; i < aiRequest.output.length; i++) {
|
||||
const message = aiRequest.output[i];
|
||||
|
||||
if (message.type === 'message' && message.role === 'assistant') {
|
||||
// Process function calls in this message
|
||||
message.content.forEach(content => {
|
||||
if (content.type === 'function_call') {
|
||||
// Initialize with null output - will be updated if we find a matching output
|
||||
functionCallsToOutputs.set(content, null);
|
||||
|
||||
// Store function call by call_id for later matching
|
||||
functionCallsByCallId.set(content.call_id, content);
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'function_call_output') {
|
||||
// Find the corresponding function calls with this call_id
|
||||
const functionCall = functionCallsByCallId.get(message.call_id);
|
||||
functionCallsByCallId.delete(message.call_id);
|
||||
|
||||
// Match with the most recent function call with this call_id
|
||||
if (functionCall) {
|
||||
functionCallsToOutputs.set(functionCall, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functionCallsToOutputs;
|
||||
};
|
||||
|
||||
export const getFunctionCallsToProcess = ({
|
||||
aiRequest,
|
||||
editorFunctionCallResults,
|
||||
}: {|
|
||||
aiRequest: AiRequest,
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null,
|
||||
|}): Array<AiRequestMessageAssistantFunctionCall> => {
|
||||
const functionCallsToProcess: AiRequestMessageAssistantFunctionCall[] = [];
|
||||
const appliedFunctionCallIds = new Set<string>();
|
||||
const alreadyProcessedFunctionCallIds = new Set<string>();
|
||||
|
||||
// Track already applied function calls
|
||||
(editorFunctionCallResults || []).forEach(functionCallOutput => {
|
||||
appliedFunctionCallIds.add(functionCallOutput.call_id);
|
||||
});
|
||||
|
||||
// Process from the end and collect function calls until we hit a message with no function calls
|
||||
let foundFunctionCall = false;
|
||||
|
||||
for (let i = aiRequest.output.length - 1; i >= 0; i--) {
|
||||
const message = aiRequest.output[i];
|
||||
|
||||
// Track already processed function call outputs
|
||||
if (message.type === 'function_call_output') {
|
||||
alreadyProcessedFunctionCallIds.add(message.call_id);
|
||||
}
|
||||
|
||||
// Collect function calls that need processing
|
||||
if (message.type === 'message' && message.role === 'assistant') {
|
||||
const functionCalls = message.content.filter(
|
||||
content => content.type === 'function_call'
|
||||
);
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
foundFunctionCall = true;
|
||||
|
||||
// Add new unique function calls that haven't been processed or applied
|
||||
for (let j = functionCalls.length - 1; j >= 0; j--) {
|
||||
const functionCall = functionCalls[j];
|
||||
if (functionCall.type !== 'function_call') continue;
|
||||
|
||||
if (
|
||||
!alreadyProcessedFunctionCallIds.has(functionCall.call_id) &&
|
||||
!appliedFunctionCallIds.has(functionCall.call_id)
|
||||
) {
|
||||
functionCallsToProcess.unshift(functionCall); // Add to beginning to preserve original order
|
||||
}
|
||||
}
|
||||
} else if (foundFunctionCall) {
|
||||
// If we've found function calls and now hit a message with no function calls, stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functionCallsToProcess;
|
||||
};
|
||||
|
||||
export const getFunctionCallOutputsFromEditorFunctionCallResults = (
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null
|
||||
): {|
|
||||
hasUnfinishedResult: boolean,
|
||||
functionCallOutputs: Array<AiRequestFunctionCallOutput>,
|
||||
|} => {
|
||||
if (!editorFunctionCallResults)
|
||||
return { hasUnfinishedResult: false, functionCallOutputs: [] };
|
||||
|
||||
let hasUnfinishedResult = false;
|
||||
const functionCallOutputs = editorFunctionCallResults
|
||||
.map(functionCallOutput => {
|
||||
if (functionCallOutput.status === 'finished') {
|
||||
return {
|
||||
type: 'function_call_output',
|
||||
call_id: functionCallOutput.call_id,
|
||||
output: JSON.stringify({
|
||||
success: functionCallOutput.success,
|
||||
...functionCallOutput.output,
|
||||
}),
|
||||
};
|
||||
} else if (functionCallOutput.status === 'ignored') {
|
||||
return {
|
||||
type: 'function_call_output',
|
||||
call_id: functionCallOutput.call_id,
|
||||
output: JSON.stringify({
|
||||
ignored: true,
|
||||
message: 'This was marked as ignored by the user.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
hasUnfinishedResult = true;
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
functionCallOutputs,
|
||||
hasUnfinishedResult,
|
||||
};
|
||||
};
|
37
newIDE/app/src/AiGeneration/AiRequestChat/ChatBubble.js
Normal file
37
newIDE/app/src/AiGeneration/AiRequestChat/ChatBubble.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import classes from './ChatBubble.module.css';
|
||||
import Paper from '../../UI/Paper';
|
||||
|
||||
const styles = {
|
||||
chatBubble: {
|
||||
paddingTop: 5,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
};
|
||||
|
||||
type ChatBubbleProps = {|
|
||||
children: React.Node,
|
||||
feedbackButtons?: React.Node,
|
||||
role: 'assistant' | 'user',
|
||||
|};
|
||||
|
||||
export const ChatBubble = ({
|
||||
children,
|
||||
feedbackButtons,
|
||||
role,
|
||||
}: ChatBubbleProps) => {
|
||||
return (
|
||||
<div className={classes.chatBubbleContainer}>
|
||||
<Paper
|
||||
background={role === 'user' ? 'light' : 'medium'}
|
||||
style={styles.chatBubble}
|
||||
>
|
||||
<div className={classes.chatBubbleContent}>{children}</div>
|
||||
{feedbackButtons}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,35 @@
|
||||
@keyframes chat-bubble-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes content-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
/* Start after the bubble container. */
|
||||
37% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chatBubbleContainer {
|
||||
display: flex;
|
||||
animation: chat-bubble-appear 0.3s;
|
||||
min-width: 0; /** Prevents horizontal overflow */
|
||||
}
|
||||
|
||||
.chatBubbleContent {
|
||||
display: flex;
|
||||
animation: content-appear 0.8s;
|
||||
min-width: 0; /** Prevents horizontal overflow */
|
||||
}
|
@@ -4,8 +4,8 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { I18n } from '@lingui/react';
|
||||
import classNames from 'classnames';
|
||||
import Window from '../../../Utils/Window';
|
||||
import { getHelpLink } from '../../../Utils/HelpLink';
|
||||
import Window from '../../Utils/Window';
|
||||
import { getHelpLink } from '../../Utils/HelpLink';
|
||||
import classes from './ChatMarkdownText.module.css';
|
||||
import {
|
||||
type ConceptMetadata,
|
||||
@@ -127,7 +127,7 @@ type Props = {|
|
||||
/**
|
||||
* Display a markdown text for a AI chat bubble.
|
||||
*/
|
||||
export const ChatMarkdownText = (props: Props) => {
|
||||
export const ChatMarkdownText = React.memo<Props>((props: Props) => {
|
||||
const { getConceptMetadataFromHref } = useGetConceptMetadata();
|
||||
const renderChatLink = React.useMemo(
|
||||
() => makeRenderChatLink({ getConceptMetadataFromHref }),
|
||||
@@ -169,4 +169,4 @@ export const ChatMarkdownText = (props: Props) => {
|
||||
});
|
||||
|
||||
return <span className={className}>{markdownElement}</span>;
|
||||
};
|
||||
});
|
258
newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js
Normal file
258
newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { ChatBubble } from './ChatBubble';
|
||||
import { Line } from '../../UI/Grid';
|
||||
import { ChatMarkdownText } from './ChatMarkdownText';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import { getFunctionCallToFunctionCallOutputMap } from './AiRequestUtils';
|
||||
import { FunctionCallRow } from './FunctionCallRow';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import Like from '../../UI/CustomSvgIcons/Like';
|
||||
import Dislike from '../../UI/CustomSvgIcons/Dislike';
|
||||
import Copy from '../../UI/CustomSvgIcons/Copy';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
type AiRequest,
|
||||
type AiRequestMessageAssistantFunctionCall,
|
||||
} from '../../Utils/GDevelopServices/Generation';
|
||||
import {
|
||||
type EditorFunctionCallResult,
|
||||
type EditorCallbacks,
|
||||
} from '../../EditorFunctions';
|
||||
import classes from './ChatMessages.module.css';
|
||||
import { DislikeFeedbackDialog } from './DislikeFeedbackDialog';
|
||||
import LeftLoader from '../../UI/LeftLoader';
|
||||
import Text from '../../UI/Text';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
|
||||
type Props = {|
|
||||
aiRequest: AiRequest,
|
||||
onSendFeedback: (
|
||||
aiRequestId: string,
|
||||
messageIndex: number,
|
||||
feedback: 'like' | 'dislike',
|
||||
reason?: string,
|
||||
freeFormDetails?: string
|
||||
) => Promise<void>,
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null,
|
||||
onProcessFunctionCalls: (
|
||||
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
|
||||
options: ?{|
|
||||
ignore?: boolean,
|
||||
|}
|
||||
) => Promise<void>,
|
||||
editorCallbacks: EditorCallbacks,
|
||||
project: gdProject | null,
|
||||
|};
|
||||
|
||||
export const ChatMessages = React.memo<Props>(function ChatMessages({
|
||||
aiRequest,
|
||||
onSendFeedback,
|
||||
editorFunctionCallResults,
|
||||
onProcessFunctionCalls,
|
||||
editorCallbacks,
|
||||
project,
|
||||
}: Props) {
|
||||
const theme = React.useContext(GDevelopThemeContext);
|
||||
|
||||
const [messageFeedbacks, setMessageFeedbacks] = React.useState({});
|
||||
const [
|
||||
dislikeFeedbackDialogOpenedFor,
|
||||
setDislikeFeedbackDialogOpenedFor,
|
||||
] = React.useState(null);
|
||||
|
||||
const functionCallToFunctionCallOutput = aiRequest
|
||||
? getFunctionCallToFunctionCallOutputMap({
|
||||
aiRequest,
|
||||
})
|
||||
: new Map();
|
||||
|
||||
return (
|
||||
<>
|
||||
{aiRequest.output.flatMap((message, messageIndex) => {
|
||||
if (message.type === 'message' && message.role === 'user') {
|
||||
return [
|
||||
<Line key={messageIndex} justifyContent="flex-end">
|
||||
<ChatBubble role="user">
|
||||
<ChatMarkdownText
|
||||
source={message.content
|
||||
.map(messageContent => messageContent.text)
|
||||
.join('\n')}
|
||||
/>
|
||||
</ChatBubble>
|
||||
</Line>,
|
||||
];
|
||||
}
|
||||
if (message.type === 'message' && message.role === 'assistant') {
|
||||
return [
|
||||
...message.content
|
||||
.map((messageContent, messageContentIndex) => {
|
||||
const key = `messageIndex${messageIndex}-${messageContentIndex}`;
|
||||
if (messageContent.type === 'output_text') {
|
||||
const feedbackKey = `${messageIndex}-${messageContentIndex}`;
|
||||
const currentFeedback = messageFeedbacks[feedbackKey];
|
||||
|
||||
return (
|
||||
<Line key={key} justifyContent="flex-start">
|
||||
<ChatBubble
|
||||
role="assistant"
|
||||
feedbackButtons={
|
||||
<div className={classes.feedbackButtonsContainer}>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`Copy`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
messageContent.text
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`This was helpful`}
|
||||
onClick={() => {
|
||||
setMessageFeedbacks({
|
||||
...messageFeedbacks,
|
||||
[feedbackKey]: 'like',
|
||||
});
|
||||
onSendFeedback(
|
||||
aiRequest.id,
|
||||
messageIndex,
|
||||
'like'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Like
|
||||
fontSize="small"
|
||||
htmlColor={
|
||||
currentFeedback === 'like'
|
||||
? theme.message.valid
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`This needs improvement`}
|
||||
onClick={() => {
|
||||
setMessageFeedbacks({
|
||||
...messageFeedbacks,
|
||||
[feedbackKey]: 'dislike',
|
||||
});
|
||||
setDislikeFeedbackDialogOpenedFor({
|
||||
aiRequestId: aiRequest.id,
|
||||
messageIndex,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Dislike
|
||||
fontSize="small"
|
||||
htmlColor={
|
||||
currentFeedback === 'dislike'
|
||||
? theme.message.warning
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatMarkdownText source={messageContent.text} />
|
||||
</ChatBubble>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
if (messageContent.type === 'reasoning') {
|
||||
return (
|
||||
<Line key={key} justifyContent="flex-start">
|
||||
<ChatBubble role="assistant">
|
||||
<ChatMarkdownText
|
||||
source={messageContent.summary.text}
|
||||
/>
|
||||
</ChatBubble>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
if (messageContent.type === 'function_call') {
|
||||
const existingFunctionCallOutput = functionCallToFunctionCallOutput.get(
|
||||
messageContent
|
||||
);
|
||||
// If there is already an existing function call output,
|
||||
// there can't be an editor function call result.
|
||||
// Indeed, sometimes, two functions will
|
||||
// have the same call_id (because of the way some LLM APIs are implemented).
|
||||
// The editorFunctionCallResult always applies to the last function call,
|
||||
// which has no function call output associated to it yet.
|
||||
const editorFunctionCallResult =
|
||||
(!existingFunctionCallOutput &&
|
||||
editorFunctionCallResults &&
|
||||
editorFunctionCallResults.find(
|
||||
functionCallOutput =>
|
||||
functionCallOutput.call_id === messageContent.call_id
|
||||
)) ||
|
||||
null;
|
||||
return (
|
||||
<FunctionCallRow
|
||||
project={project}
|
||||
key={key}
|
||||
onProcessFunctionCalls={onProcessFunctionCalls}
|
||||
functionCall={messageContent}
|
||||
editorFunctionCallResult={editorFunctionCallResult}
|
||||
existingFunctionCallOutput={existingFunctionCallOutput}
|
||||
editorCallbacks={editorCallbacks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
];
|
||||
}
|
||||
if (message.type === 'function_call_output') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
})}
|
||||
|
||||
{aiRequest.status === 'error' ? (
|
||||
<Line justifyContent="flex-start">
|
||||
<AlertMessage kind="error">
|
||||
<Trans>
|
||||
The AI encountered an error while handling your request - this was
|
||||
request was not counted in your AI usage. Try again later.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
) : aiRequest.status === 'working' ? (
|
||||
<Line justifyContent="flex-start">
|
||||
<div className={classes.thinkingText}>
|
||||
<LeftLoader isLoading>
|
||||
<Text noMargin displayInlineAsSpan>
|
||||
<Trans>Thinking about your request...</Trans>
|
||||
</Text>
|
||||
</LeftLoader>
|
||||
</div>
|
||||
</Line>
|
||||
) : null}
|
||||
{dislikeFeedbackDialogOpenedFor && (
|
||||
<DislikeFeedbackDialog
|
||||
mode={aiRequest.mode || 'chat'}
|
||||
open
|
||||
onClose={() => setDislikeFeedbackDialogOpenedFor(null)}
|
||||
onSendFeedback={(reason: string, freeFormDetails: string) => {
|
||||
onSendFeedback(
|
||||
dislikeFeedbackDialogOpenedFor.aiRequestId,
|
||||
dislikeFeedbackDialogOpenedFor.messageIndex,
|
||||
'dislike',
|
||||
reason,
|
||||
freeFormDetails
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@@ -0,0 +1,26 @@
|
||||
@keyframes thinking-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
/* Start after the bubble container and when the bubble content is almost finished animating. */
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.thinkingText {
|
||||
animation: thinking-appear 1s;
|
||||
}
|
||||
|
||||
.feedbackButtonsContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
@@ -1,22 +1,22 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Window from '../../../Utils/Window';
|
||||
import memoize from '../../../Utils/Memoize';
|
||||
import { useRefWithInit } from '../../../Utils/UseRefInitHook';
|
||||
import { getHelpLink } from '../../../Utils/HelpLink';
|
||||
import { ExtensionStoreContext } from '../../../AssetStore/ExtensionStore/ExtensionStoreContext';
|
||||
import { IconContainer } from '../../../UI/IconContainer';
|
||||
import Window from '../../Utils/Window';
|
||||
import memoize from '../../Utils/Memoize';
|
||||
import { useRefWithInit } from '../../Utils/UseRefInitHook';
|
||||
import { getHelpLink } from '../../Utils/HelpLink';
|
||||
import { ExtensionStoreContext } from '../../AssetStore/ExtensionStore/ExtensionStoreContext';
|
||||
import { IconContainer } from '../../UI/IconContainer';
|
||||
import {
|
||||
type ExtensionShortHeader,
|
||||
type EventsFunctionInsideExtensionShortHeader,
|
||||
} from '../../../Utils/GDevelopServices/Extension';
|
||||
import { mapFor } from '../../../Utils/MapFor';
|
||||
} from '../../Utils/GDevelopServices/Extension';
|
||||
import { mapFor } from '../../Utils/MapFor';
|
||||
import classes from './ChatMarkdownText.module.css';
|
||||
import classNames from 'classnames';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import { MarkdownText } from '../../../UI/MarkdownText';
|
||||
import Text from '../../../UI/Text';
|
||||
import { MarkdownText } from '../../UI/MarkdownText';
|
||||
import Text from '../../UI/Text';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
@@ -0,0 +1,178 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { ColumnStackLayout } from '../../UI/Layout';
|
||||
import Text from '../../UI/Text';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import Dialog, { DialogPrimaryButton } from '../../UI/Dialog';
|
||||
import Radio from '@material-ui/core/Radio';
|
||||
import RadioGroup from '@material-ui/core/RadioGroup';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
import FlatButton from '../../UI/FlatButton';
|
||||
import { CompactTextAreaField } from '../../UI/CompactTextAreaField';
|
||||
|
||||
type DislikeFeedbackDialogProps = {|
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onSendFeedback: (reason: string, freeFormDetails: string) => void,
|
||||
mode: 'chat' | 'agent',
|
||||
|};
|
||||
|
||||
export const DislikeFeedbackDialog = ({
|
||||
mode,
|
||||
open,
|
||||
onClose,
|
||||
onSendFeedback,
|
||||
}: DislikeFeedbackDialogProps) => {
|
||||
const [selectedReason, setSelectedReason] = React.useState<?string>(null);
|
||||
const [freeFormDetails, setFreeFormDetails] = React.useState<string>('');
|
||||
|
||||
const handleChange = (event: { target: { value: string } }) => {
|
||||
setSelectedReason(event.target.value);
|
||||
};
|
||||
|
||||
const handleSendFeedback = () => {
|
||||
if (selectedReason) {
|
||||
onSendFeedback(selectedReason, freeFormDetails);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<Dialog
|
||||
title={
|
||||
mode === 'agent' ? (
|
||||
<Trans>What went wrong?</Trans>
|
||||
) : (
|
||||
<Trans>What could be improved?</Trans>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<FlatButton
|
||||
key="cancel"
|
||||
label={<Trans>Cancel</Trans>}
|
||||
onClick={onClose}
|
||||
/>,
|
||||
<DialogPrimaryButton
|
||||
key="send"
|
||||
primary
|
||||
label={<Trans>Send feedback</Trans>}
|
||||
onClick={handleSendFeedback}
|
||||
disabled={!selectedReason}
|
||||
/>,
|
||||
]}
|
||||
open={open}
|
||||
onRequestClose={onClose}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text>
|
||||
{mode === 'agent' ? (
|
||||
<Trans>
|
||||
The AI agent is in beta. Help us make it better by telling us
|
||||
what went wrong:
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Help us improve by telling us what could be improved:
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<RadioGroup value={selectedReason || ''} onChange={handleChange}>
|
||||
{mode === 'agent' ? (
|
||||
<>
|
||||
<FormControlLabel
|
||||
value="not-as-good-as-it-could-be"
|
||||
control={<Radio color="secondary" />}
|
||||
label={
|
||||
<Trans>
|
||||
The result wasn't as good as it could have been
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="too-little-work"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>It didn't do enough</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="does-not-work"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>It didn't work at all</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="too-much-modified-or-broken"
|
||||
control={<Radio color="secondary" />}
|
||||
label={
|
||||
<Trans>Too many things were changed or broken</Trans>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControlLabel
|
||||
value="not-in-my-language"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is not in my language</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="non-existing-things"
|
||||
control={<Radio color="secondary" />}
|
||||
label={
|
||||
<Trans>
|
||||
Some things in the answer don't exist in GDevelop
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="not-as-good-as-it-could-be"
|
||||
control={<Radio color="secondary" />}
|
||||
label={
|
||||
<Trans>The answer is not as good as it could be</Trans>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="very-wrong-answer"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is entirely wrong</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="out-of-scope"
|
||||
control={<Radio color="secondary" />}
|
||||
label={
|
||||
<Trans>The answer is out of scope for GDevelop</Trans>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="too-short"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is too short</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="too-long"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is too long</Trans>}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<FormControlLabel
|
||||
value="other"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>Other reason</Trans>}
|
||||
/>
|
||||
</RadioGroup>
|
||||
<CompactTextAreaField
|
||||
label={i18n._(t`More details (optional)`)}
|
||||
value={freeFormDetails}
|
||||
onChange={value => setFreeFormDetails(value)}
|
||||
rows={5}
|
||||
maxLength={10000}
|
||||
/>
|
||||
</ColumnStackLayout>
|
||||
</Dialog>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
};
|
89
newIDE/app/src/AiGeneration/AiRequestChat/FeedbackBanner.js
Normal file
89
newIDE/app/src/AiGeneration/AiRequestChat/FeedbackBanner.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Column, Line } from '../../UI/Grid';
|
||||
import Paper from '../../UI/Paper';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import Like from '../../UI/CustomSvgIcons/Like';
|
||||
import Dislike from '../../UI/CustomSvgIcons/Dislike';
|
||||
import Text from '../../UI/Text';
|
||||
import classes from './FeedbackBanner.module.css';
|
||||
import { DislikeFeedbackDialog } from './DislikeFeedbackDialog';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
|
||||
type Props = {
|
||||
onSendFeedback: (
|
||||
feedback: 'like' | 'dislike',
|
||||
reason?: string,
|
||||
freeFormDetails?: string
|
||||
) => void,
|
||||
};
|
||||
|
||||
export const FeedbackBanner = ({ onSendFeedback }: Props) => {
|
||||
const [currentFeedback, setCurrentFeedback] = React.useState<
|
||||
'like' | 'dislike' | null
|
||||
>(null);
|
||||
const theme = React.useContext(GDevelopThemeContext);
|
||||
const [
|
||||
dislikeFeedbackDialogOpened,
|
||||
setDislikeFeedbackDialogOpened,
|
||||
] = React.useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Line noMargin justifyContent="center">
|
||||
<Paper background="dark" variant="outlined">
|
||||
<Column expand>
|
||||
<div className={classes.textAndButtonsContainer}>
|
||||
<Text size="block-title" color="inherit">
|
||||
<Trans>Did it work?</Trans>
|
||||
</Text>
|
||||
<Line alignItems="center" noMargin neverShrink>
|
||||
<IconButton
|
||||
tooltip={t`This was helpful`}
|
||||
onClick={() => {
|
||||
setCurrentFeedback('like');
|
||||
onSendFeedback('like');
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
<Like
|
||||
htmlColor={
|
||||
currentFeedback === 'like' ? theme.message.valid : undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip={t`There was a problem`}
|
||||
onClick={() => {
|
||||
setDislikeFeedbackDialogOpened(true);
|
||||
}}
|
||||
color="inherit"
|
||||
>
|
||||
<Dislike
|
||||
htmlColor={
|
||||
currentFeedback === 'dislike'
|
||||
? theme.message.warning
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Line>
|
||||
</div>
|
||||
</Column>
|
||||
</Paper>
|
||||
{dislikeFeedbackDialogOpened && (
|
||||
<DislikeFeedbackDialog
|
||||
mode="agent"
|
||||
open={dislikeFeedbackDialogOpened}
|
||||
onClose={() => setDislikeFeedbackDialogOpened(false)}
|
||||
onSendFeedback={(reason: string, freeFormDetails: string) => {
|
||||
setDislikeFeedbackDialogOpened(false);
|
||||
onSendFeedback('dislike', reason, freeFormDetails);
|
||||
setCurrentFeedback('dislike');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
);
|
||||
};
|
@@ -0,0 +1,12 @@
|
||||
.textAndButtonsContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 2px;
|
||||
color: var(--theme-text-secondary-color);
|
||||
opacity: 0.9;
|
||||
min-width: 0;
|
||||
}
|
228
newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js
Normal file
228
newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js
Normal file
@@ -0,0 +1,228 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
type AiRequestMessageAssistantFunctionCall,
|
||||
type AiRequestFunctionCallOutput,
|
||||
} from '../../Utils/GDevelopServices/Generation';
|
||||
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
|
||||
import CircularProgress from '../../UI/CircularProgress';
|
||||
import { Tooltip } from '@material-ui/core';
|
||||
import Text from '../../UI/Text';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import FlatButtonWithSplitMenu from '../../UI/FlatButtonWithSplitMenu';
|
||||
import Check from '../../UI/CustomSvgIcons/Check';
|
||||
import Error from '../../UI/CustomSvgIcons/Error';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import classes from './FunctionCallRow.module.css';
|
||||
import {
|
||||
editorFunctions,
|
||||
type EditorFunction,
|
||||
type EditorCallbacks,
|
||||
} from '../../EditorFunctions';
|
||||
import Link from '../../UI/Link';
|
||||
import { LineStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout';
|
||||
import ChevronArrowRight from '../../UI/CustomSvgIcons/ChevronArrowRight';
|
||||
import ChevronArrowBottom from '../../UI/CustomSvgIcons/ChevronArrowBottom';
|
||||
import Paper from '../../UI/Paper';
|
||||
import { Line, Column } from '../../UI/Grid';
|
||||
|
||||
type Props = {|
|
||||
project: gdProject | null,
|
||||
functionCall: AiRequestMessageAssistantFunctionCall,
|
||||
editorFunctionCallResult: ?EditorFunctionCallResult,
|
||||
existingFunctionCallOutput: ?AiRequestFunctionCallOutput,
|
||||
onProcessFunctionCalls: (
|
||||
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
|
||||
options: ?{|
|
||||
ignore?: boolean,
|
||||
|}
|
||||
) => Promise<void>,
|
||||
editorCallbacks: EditorCallbacks,
|
||||
|};
|
||||
|
||||
export const FunctionCallRow = React.memo<Props>(function FunctionCallRow({
|
||||
project,
|
||||
functionCall,
|
||||
editorFunctionCallResult,
|
||||
existingFunctionCallOutput,
|
||||
onProcessFunctionCalls,
|
||||
editorCallbacks,
|
||||
}: Props) {
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
const gdevelopTheme = React.useContext(GDevelopThemeContext);
|
||||
|
||||
let existingParsedOutput;
|
||||
try {
|
||||
if (existingFunctionCallOutput) {
|
||||
// While this could be slightly expensive in a component to render, the component
|
||||
// is memoized, so this won't impact rendering of large chats.
|
||||
existingParsedOutput = JSON.parse(existingFunctionCallOutput.output);
|
||||
}
|
||||
} catch (error) {
|
||||
existingParsedOutput = null;
|
||||
}
|
||||
|
||||
const isIgnored =
|
||||
(!!editorFunctionCallResult &&
|
||||
editorFunctionCallResult.status === 'ignored') ||
|
||||
(existingParsedOutput && !!existingParsedOutput.ignored);
|
||||
const isFinished =
|
||||
!!existingFunctionCallOutput ||
|
||||
(!!editorFunctionCallResult &&
|
||||
editorFunctionCallResult.status === 'finished');
|
||||
const functionCallResultIsErrored =
|
||||
editorFunctionCallResult &&
|
||||
editorFunctionCallResult.status === 'finished' &&
|
||||
editorFunctionCallResult.success === false;
|
||||
const hasErrored =
|
||||
functionCallResultIsErrored ||
|
||||
(existingParsedOutput && existingParsedOutput.success === false);
|
||||
const isWorking =
|
||||
!isFinished &&
|
||||
!!editorFunctionCallResult &&
|
||||
editorFunctionCallResult.status === 'working';
|
||||
|
||||
const editorFunction: EditorFunction | null =
|
||||
editorFunctions[functionCall.name] || null;
|
||||
let text;
|
||||
let details;
|
||||
let hasDetailsToShow = false;
|
||||
if (!editorFunction) {
|
||||
text = (
|
||||
<Trans>
|
||||
The AI tried to use a function of the editor that is unknown.
|
||||
</Trans>
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const result = editorFunction.renderForEditor({
|
||||
project,
|
||||
args: JSON.parse(functionCall.arguments),
|
||||
editorCallbacks,
|
||||
shouldShowDetails: showDetails,
|
||||
});
|
||||
|
||||
text = result.text;
|
||||
details = result.details;
|
||||
hasDetailsToShow = result.hasDetailsToShow;
|
||||
} catch (error) {
|
||||
console.error('Error rendering function call:', error);
|
||||
text = (
|
||||
<Trans>
|
||||
The editor was unable to display the operation ({functionCall.name})
|
||||
used by the AI.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.functionCallContainer}>
|
||||
<LineStackLayout noMargin alignItems="center">
|
||||
<Tooltip
|
||||
title={JSON.stringify(
|
||||
existingFunctionCallOutput || editorFunctionCallResult
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{hasErrored ? (
|
||||
<Error htmlColor={gdevelopTheme.message.error} />
|
||||
) : isIgnored ? (
|
||||
<Check htmlColor={gdevelopTheme.text.color.disabled} />
|
||||
) : isFinished ? (
|
||||
<Check htmlColor={gdevelopTheme.message.valid} />
|
||||
) : (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
value={100}
|
||||
variant={isWorking ? 'indeterminate' : 'determinate'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<ResponsiveLineStackLayout
|
||||
justifyContent="space-between"
|
||||
expand
|
||||
noOverflowParent
|
||||
>
|
||||
<LineStackLayout noMargin alignItems="baseline">
|
||||
<Text>{text || 'Working...'}</Text>
|
||||
{hasDetailsToShow && (
|
||||
<Text size="body-small" color="secondary">
|
||||
<Link
|
||||
color="inherit"
|
||||
href={'#'}
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<Trans>Details</Trans>
|
||||
{details ? (
|
||||
<ChevronArrowBottom
|
||||
fontSize="small"
|
||||
style={{
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChevronArrowRight
|
||||
fontSize="small"
|
||||
style={{
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
<LineStackLayout
|
||||
noMargin
|
||||
alignItems="baseline"
|
||||
justifyContent="flex-end"
|
||||
neverShrink
|
||||
>
|
||||
{!isFinished && !isWorking && (
|
||||
<FlatButtonWithSplitMenu
|
||||
primary
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => onProcessFunctionCalls([functionCall])}
|
||||
label={<Trans>Execute this action</Trans>}
|
||||
buildMenuTemplate={i18n => [
|
||||
{
|
||||
label: i18n._(t`Ignore this`),
|
||||
click: () => {
|
||||
onProcessFunctionCalls([functionCall], {
|
||||
ignore: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{functionCallResultIsErrored && (
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
onClick={() => onProcessFunctionCalls([functionCall])}
|
||||
label={<Trans>Retry</Trans>}
|
||||
/>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
</ResponsiveLineStackLayout>
|
||||
</LineStackLayout>
|
||||
{details && (
|
||||
<div className={classes.detailsPaperContainer}>
|
||||
<Paper background="medium" elevation={0} square variant="outlined">
|
||||
<Line expand>
|
||||
<Column expand>
|
||||
<Text noMargin color="secondary">
|
||||
{details}
|
||||
</Text>
|
||||
</Column>
|
||||
</Line>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@@ -0,0 +1,38 @@
|
||||
@keyframes function-call-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
/* Start after the bubble container and when the bubble content is almost finished animating. */
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.functionCallContainer {
|
||||
animation: function-call-appear 0.5s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@keyframes details-paper-appear {
|
||||
0% {
|
||||
transform: scaleY(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.detailsPaperContainer {
|
||||
transform-origin: top left;
|
||||
animation: details-paper-appear 0.1s;
|
||||
}
|
815
newIDE/app/src/AiGeneration/AiRequestChat/index.js
Normal file
815
newIDE/app/src/AiGeneration/AiRequestChat/index.js
Normal file
@@ -0,0 +1,815 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { I18n as I18nType } from '@lingui/core';
|
||||
import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout';
|
||||
import Text from '../../UI/Text';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
type AiRequest,
|
||||
type AiRequestMessageAssistantFunctionCall,
|
||||
} from '../../Utils/GDevelopServices/Generation';
|
||||
import RaisedButton from '../../UI/RaisedButton';
|
||||
import { CompactTextAreaFieldWithControls } from '../../UI/CompactTextAreaFieldWithControls';
|
||||
import { Column, Line, Spacer } from '../../UI/Grid';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import Paper from '../../UI/Paper';
|
||||
import ScrollView, { type ScrollViewInterface } from '../../UI/ScrollView';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
import classes from './AiRequestChat.module.css';
|
||||
import RobotIcon from '../../ProjectCreation/RobotIcon';
|
||||
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import GetSubscriptionCard from '../../Profile/Subscription/GetSubscriptionCard';
|
||||
import {
|
||||
type Quota,
|
||||
type UsagePrice,
|
||||
} from '../../Utils/GDevelopServices/Usage';
|
||||
import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
|
||||
import Link from '../../UI/Link';
|
||||
import { getHelpLink } from '../../Utils/HelpLink';
|
||||
import Window from '../../Utils/Window';
|
||||
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
|
||||
import { type EditorCallbacks } from '../../EditorFunctions';
|
||||
import { getFunctionCallsToProcess } from './AiRequestUtils';
|
||||
import CircularProgress from '../../UI/CircularProgress';
|
||||
import TwoStatesButton from '../../UI/TwoStatesButton';
|
||||
import Help from '../../UI/CustomSvgIcons/Help';
|
||||
import Hammer from '../../UI/CustomSvgIcons/Hammer';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
import Send from '../../UI/CustomSvgIcons/Send';
|
||||
import { FeedbackBanner } from './FeedbackBanner';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
|
||||
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
|
||||
|
||||
const styles = {
|
||||
chatScrollView: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 14,
|
||||
maskImage: 'linear-gradient(to bottom, transparent, black 14px)',
|
||||
maskSize: '100% 100%',
|
||||
maskRepeat: 'no-repeat',
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
project: gdProject | null,
|
||||
i18n: I18nType,
|
||||
aiRequest: AiRequest | null,
|
||||
|
||||
isSending: boolean,
|
||||
onStartNewAiRequest: (options: {|
|
||||
userRequest: string,
|
||||
mode: 'chat' | 'agent',
|
||||
|}) => void,
|
||||
onSendMessage: (options: {|
|
||||
userMessage: string,
|
||||
|}) => Promise<void>,
|
||||
onSendFeedback: (
|
||||
aiRequestId: string,
|
||||
messageIndex: number,
|
||||
feedback: 'like' | 'dislike',
|
||||
reason?: string,
|
||||
freeFormDetails?: string
|
||||
) => Promise<void>,
|
||||
hasOpenedProject: boolean,
|
||||
isAutoProcessingFunctionCalls: boolean,
|
||||
setAutoProcessFunctionCalls: boolean => void,
|
||||
onStartNewChat: () => void,
|
||||
|
||||
onProcessFunctionCalls: (
|
||||
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
|
||||
options: ?{| ignore?: boolean |}
|
||||
) => Promise<void>,
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null,
|
||||
editorCallbacks: EditorCallbacks,
|
||||
// Error that occurred while sending the last request.
|
||||
lastSendError: ?Error,
|
||||
|
||||
// Quota available for using the feature.
|
||||
quota: Quota | null,
|
||||
increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none',
|
||||
price: UsagePrice | null,
|
||||
availableCredits: number,
|
||||
};
|
||||
|
||||
export type AiRequestChatInterface = {|
|
||||
resetUserInput: (aiRequestId: string | null) => void,
|
||||
|};
|
||||
|
||||
const getQuotaOrCreditsText = ({
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
price,
|
||||
availableCredits,
|
||||
isMobile,
|
||||
}: {|
|
||||
quota: Quota | null,
|
||||
increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none',
|
||||
price: UsagePrice | null,
|
||||
availableCredits: number,
|
||||
isMobile: boolean,
|
||||
|}) => {
|
||||
if (!quota) return null;
|
||||
|
||||
const quotaOrCreditsText = !quota.limitReached ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
{increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
Get GDevelop premium to get more free requests every month.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
These are parts of your GDevelop premium membership ({quota.max}{' '}
|
||||
free requests per month).
|
||||
</Trans>
|
||||
)}{' '}
|
||||
<Trans>Free requests do not consume credits on your account.</Trans>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{isMobile ? (
|
||||
<Trans>{quota.max - quota.current} free requests left</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{quota.max - quota.current} of {quota.max} free requests left this
|
||||
month
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Trans>{Math.max(0, availableCredits)} credits available</Trans>
|
||||
);
|
||||
|
||||
return quotaOrCreditsText;
|
||||
};
|
||||
|
||||
const getPriceText = ({
|
||||
aiRequestMode,
|
||||
price,
|
||||
lastUserMessagePriceInCredits,
|
||||
}: {|
|
||||
aiRequestMode: 'chat' | 'agent',
|
||||
price: UsagePrice | null,
|
||||
lastUserMessagePriceInCredits: number | null,
|
||||
|}) => {
|
||||
if (!price) return null;
|
||||
|
||||
const priceInCredits = price.priceInCredits;
|
||||
const maximumPriceInCredits =
|
||||
(price.variablePrice &&
|
||||
price.variablePrice[aiRequestMode] &&
|
||||
price.variablePrice[aiRequestMode]['default'] &&
|
||||
price.variablePrice[aiRequestMode]['default'].maximumPriceInCredits) ||
|
||||
null;
|
||||
const minimumPriceInCredits =
|
||||
(price.variablePrice &&
|
||||
price.variablePrice[aiRequestMode] &&
|
||||
price.variablePrice[aiRequestMode]['default'] &&
|
||||
price.variablePrice[aiRequestMode]['default'].minimumPriceInCredits) ||
|
||||
null;
|
||||
|
||||
const priceText = maximumPriceInCredits ? (
|
||||
<Trans>
|
||||
{minimumPriceInCredits || priceInCredits} to {maximumPriceInCredits}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>{minimumPriceInCredits || priceInCredits}</Trans>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
aiRequestMode === 'agent' ? (
|
||||
<>
|
||||
<Trans>
|
||||
Each request to the AI agent costs {priceText} credits. It depends
|
||||
on the amount of work the agent will do and the number of times it
|
||||
generates events.
|
||||
</Trans>{' '}
|
||||
{lastUserMessagePriceInCredits ? (
|
||||
<Trans>
|
||||
The last request used {lastUserMessagePriceInCredits} credits.
|
||||
</Trans>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Trans>Each answer from the AI costs {priceText} credits.</Trans>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
{aiRequestMode === 'agent' ? (
|
||||
<Hammer fontSize="small" />
|
||||
) : (
|
||||
<Help fontSize="small" />
|
||||
)}
|
||||
{aiRequestMode === 'agent' ? (
|
||||
<Trans>{priceText} credits/request</Trans>
|
||||
) : (
|
||||
<Trans>{priceText} credits/answer</Trans>
|
||||
)}
|
||||
</LineStackLayout>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
|
||||
(
|
||||
{
|
||||
project,
|
||||
aiRequest,
|
||||
isSending,
|
||||
onStartNewAiRequest,
|
||||
onSendMessage,
|
||||
onSendFeedback,
|
||||
onStartNewChat,
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
lastSendError,
|
||||
price,
|
||||
availableCredits,
|
||||
hasOpenedProject,
|
||||
editorFunctionCallResults,
|
||||
onProcessFunctionCalls,
|
||||
isAutoProcessingFunctionCalls,
|
||||
setAutoProcessFunctionCalls,
|
||||
i18n,
|
||||
editorCallbacks,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
// TODO: store the default mode in the user preferences?
|
||||
const [newAiRequestMode, setNewAiRequestMode] = React.useState<
|
||||
'chat' | 'agent'
|
||||
>('agent');
|
||||
const aiRequestId: string = aiRequest ? aiRequest.id : '';
|
||||
const [
|
||||
userRequestTextPerAiRequestId,
|
||||
setUserRequestTextPerRequestId,
|
||||
] = React.useState<{ [string]: string }>({});
|
||||
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
|
||||
const requiredGameId = (aiRequest && aiRequest.gameId) || null;
|
||||
|
||||
const newChatPlaceholder = React.useMemo(
|
||||
() => {
|
||||
const newChatPlaceholders: Array<MessageDescriptor> =
|
||||
newAiRequestMode === 'agent'
|
||||
? hasOpenedProject
|
||||
? [
|
||||
t`Add solid rocks that falls from the sky at a random position around the player every 0.5 seconds`,
|
||||
t`Add a score and display it on the screen`,
|
||||
t`Create a 3D explosion when the player is hit`,
|
||||
]
|
||||
: [
|
||||
t`Build a platformer game with a score and coins to collect`,
|
||||
t`Make a quizz game with a question and 4 answers`,
|
||||
t`Make a game where the player must avoid obstacles`,
|
||||
]
|
||||
: [
|
||||
t`How to add a leaderboard?`,
|
||||
t`How to display the health of my player?`,
|
||||
t`How to add an explosion when an enemy is destroyed?`,
|
||||
t`How to create a main menu for my game?`,
|
||||
...(hasOpenedProject
|
||||
? [
|
||||
t`What would you add to my game?`,
|
||||
t`How to make my game more fun?`,
|
||||
t`What is a good GDevelop feature I could use in my game?`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return newChatPlaceholders[
|
||||
Math.floor(Math.random() * newChatPlaceholders.length)
|
||||
];
|
||||
},
|
||||
[newAiRequestMode, hasOpenedProject]
|
||||
);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
resetUserInput: (aiRequestId: string | null) => {
|
||||
const aiRequestIdToReset: string = aiRequestId || '';
|
||||
setUserRequestTextPerRequestId(userRequestTextPerAiRequestId => ({
|
||||
...userRequestTextPerAiRequestId,
|
||||
[aiRequestIdToReset]: '',
|
||||
}));
|
||||
|
||||
if (scrollViewRef.current) {
|
||||
scrollViewRef.current.scrollToBottom({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
|
||||
const priceText = (
|
||||
<Text size="body-small" color="secondary" noMargin>
|
||||
{getPriceText({
|
||||
aiRequestMode: aiRequest
|
||||
? aiRequest.mode || 'chat'
|
||||
: newAiRequestMode,
|
||||
price,
|
||||
lastUserMessagePriceInCredits:
|
||||
(aiRequest && aiRequest.lastUserMessagePriceInCredits) || null,
|
||||
}) || '\u00A0'}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const subscriptionBanner =
|
||||
quota && quota.limitReached && increaseQuotaOffering !== 'none' ? (
|
||||
<GetSubscriptionCard
|
||||
placementId="ai-requests"
|
||||
subscriptionDialogOpeningReason={
|
||||
increaseQuotaOffering === 'subscribe'
|
||||
? 'AI requests (subscribe)'
|
||||
: 'AI requests (upgrade)'
|
||||
}
|
||||
label={
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>Get GDevelop premium</Trans>
|
||||
) : (
|
||||
<Trans>Upgrade</Trans>
|
||||
)
|
||||
}
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_gold"
|
||||
canHide
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
<Text noMargin>
|
||||
{increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
Unlock AI requests included with a GDevelop premium plan.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Get even more AI requests included with a higher premium
|
||||
plan.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Line>
|
||||
</GetSubscriptionCard>
|
||||
) : null;
|
||||
|
||||
const errorText = lastSendError ? (
|
||||
<Text size="body-small" color="error" noMargin>
|
||||
<Trans>
|
||||
An error happened when sending your request, please try again.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
const quotaOrCreditsText = (
|
||||
<Text size="body-small" color="secondary" noMargin>
|
||||
{getQuotaOrCreditsText({
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
price,
|
||||
availableCredits,
|
||||
isMobile,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (!aiRequest) {
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
[classes.newChatContainer]: true,
|
||||
// Move the entire screen up when the soft keyboard is open:
|
||||
'avoid-soft-keyboard': true,
|
||||
})}
|
||||
>
|
||||
<ColumnStackLayout justifyContent="center" expand>
|
||||
<Line noMargin justifyContent="center">
|
||||
<RobotIcon rotating size={40} />
|
||||
</Line>
|
||||
<Column noMargin alignItems="center">
|
||||
<Text size="bold-title">
|
||||
{newAiRequestMode === 'agent' ? (
|
||||
<Trans>What do you want to make?</Trans>
|
||||
) : (
|
||||
<Trans>Ask any gamedev question</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Line noMargin justifyContent="center">
|
||||
<TwoStatesButton
|
||||
value={newAiRequestMode}
|
||||
leftButton={{
|
||||
icon: <Hammer fontSize="small" />,
|
||||
label: <Trans>Build for me (beta)</Trans>,
|
||||
value: 'agent',
|
||||
}}
|
||||
rightButton={{
|
||||
icon: <Help fontSize="small" />,
|
||||
label: <Trans>Ask a question</Trans>,
|
||||
value: 'chat',
|
||||
}}
|
||||
onChange={value => {
|
||||
if (value !== 'chat' && value !== 'agent') {
|
||||
return;
|
||||
}
|
||||
setNewAiRequestMode(value);
|
||||
}}
|
||||
/>
|
||||
</Line>
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onStartNewAiRequest({
|
||||
mode: newAiRequestMode,
|
||||
userRequest: userRequestTextPerAiRequestId[''],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout justifyContent="center" noMargin>
|
||||
<Column noMargin alignItems="stretch" justifyContent="stretch">
|
||||
<Spacer />
|
||||
<CompactTextAreaFieldWithControls
|
||||
maxLength={6000}
|
||||
value={userRequestTextPerAiRequestId[''] || ''}
|
||||
disabled={isSending}
|
||||
hasNeonCorner
|
||||
hasAnimatedNeonCorner={isSending}
|
||||
errored={!!lastSendError}
|
||||
onChange={userRequestText =>
|
||||
setUserRequestTextPerRequestId(
|
||||
userRequestTextPerAiRequestId => ({
|
||||
...userRequestTextPerAiRequestId,
|
||||
'': userRequestText,
|
||||
})
|
||||
)
|
||||
}
|
||||
onSubmit={() => {
|
||||
onStartNewAiRequest({
|
||||
mode: newAiRequestMode,
|
||||
userRequest: userRequestTextPerAiRequestId[''],
|
||||
});
|
||||
}}
|
||||
placeholder={newChatPlaceholder}
|
||||
rows={5}
|
||||
controls={
|
||||
<Column>
|
||||
<LineStackLayout
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
icon={<Send />}
|
||||
label={
|
||||
newAiRequestMode === 'agent' ? (
|
||||
hasOpenedProject ? (
|
||||
<Trans>Build this on my game</Trans>
|
||||
) : (
|
||||
<Trans>Start building the game</Trans>
|
||||
)
|
||||
) : (
|
||||
<Trans>Send question</Trans>
|
||||
)
|
||||
}
|
||||
style={{ flexShrink: 0 }}
|
||||
disabled={
|
||||
isSending ||
|
||||
!userRequestTextPerAiRequestId[aiRequestId]
|
||||
}
|
||||
onClick={() => {
|
||||
onStartNewAiRequest({
|
||||
mode: newAiRequestMode,
|
||||
userRequest: userRequestTextPerAiRequestId[''],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
}
|
||||
/>
|
||||
</Column>
|
||||
<Line noMargin>
|
||||
<LineStackLayout
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
expand
|
||||
>
|
||||
{errorText || priceText}
|
||||
{errorText ? null : quotaOrCreditsText}
|
||||
</LineStackLayout>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
{subscriptionBanner ? (
|
||||
<>
|
||||
<Spacer />
|
||||
{subscriptionBanner}
|
||||
</>
|
||||
) : null}
|
||||
</ColumnStackLayout>
|
||||
<Column justifyContent="center">
|
||||
{newAiRequestMode === 'agent' ? (
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
The AI agent will build simple games or features for you.{' '}
|
||||
<Link
|
||||
href={getHelpLink('/interface/ai')}
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(getHelpLink('/interface/ai'))
|
||||
}
|
||||
>
|
||||
It can inspect your game objects and events.
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
The AI chat is experimental and still being improved.{' '}
|
||||
<Link
|
||||
href={getHelpLink('/interface/ai')}
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(getHelpLink('/interface/ai'))
|
||||
}
|
||||
>
|
||||
It has access to your game objects but not events.
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{newAiRequestMode === 'agent' ? (
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
Results may vary: experiment and use it for learning.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>Answers may have mistakes: always verify them.</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userMessagesCount = aiRequest.output.filter(
|
||||
message => message.type === 'message' && message.role === 'user'
|
||||
).length;
|
||||
|
||||
const hasWorkingFunctionCalls =
|
||||
editorFunctionCallResults &&
|
||||
editorFunctionCallResults.some(
|
||||
functionCallOutput => functionCallOutput.status === 'working'
|
||||
);
|
||||
const allFunctionCallsToProcess = getFunctionCallsToProcess({
|
||||
aiRequest,
|
||||
editorFunctionCallResults,
|
||||
});
|
||||
const isPausedAndHasFunctionCallsToProcess =
|
||||
!isAutoProcessingFunctionCalls && allFunctionCallsToProcess.length > 0;
|
||||
|
||||
const lastMessageIndex = aiRequest.output.length - 1;
|
||||
const lastMessage = aiRequest.output[lastMessageIndex];
|
||||
const shouldDisplayFeedbackBanner =
|
||||
!hasWorkingFunctionCalls &&
|
||||
!isPausedAndHasFunctionCallsToProcess &&
|
||||
!isSending &&
|
||||
aiRequest.status === 'ready' &&
|
||||
aiRequest.mode === 'agent' &&
|
||||
lastMessage.type === 'message' &&
|
||||
lastMessage.role === 'assistant';
|
||||
const lastMessageFeedbackBanner = shouldDisplayFeedbackBanner && (
|
||||
<FeedbackBanner
|
||||
onSendFeedback={(
|
||||
feedback: 'like' | 'dislike',
|
||||
reason?: string,
|
||||
freeFormDetails?: string
|
||||
) => {
|
||||
onSendFeedback(
|
||||
aiRequestId,
|
||||
lastMessageIndex,
|
||||
feedback,
|
||||
reason,
|
||||
freeFormDetails
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const isForAnotherProject =
|
||||
!!requiredGameId &&
|
||||
(!project || requiredGameId !== project.getProjectUuid());
|
||||
const isForAnotherProjectText = isForAnotherProject ? (
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
This request is for another project.{' '}
|
||||
<Link href="#" onClick={onStartNewChat}>
|
||||
Start a new chat
|
||||
</Link>{' '}
|
||||
to build on a new project.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
[classes.aiRequestChatContainer]: true,
|
||||
})}
|
||||
>
|
||||
<ScrollView ref={scrollViewRef} style={styles.chatScrollView}>
|
||||
<ChatMessages
|
||||
aiRequest={aiRequest}
|
||||
onSendFeedback={onSendFeedback}
|
||||
editorFunctionCallResults={editorFunctionCallResults}
|
||||
editorCallbacks={editorCallbacks}
|
||||
project={project}
|
||||
onProcessFunctionCalls={onProcessFunctionCalls}
|
||||
/>
|
||||
<Spacer />
|
||||
<ColumnStackLayout noMargin>
|
||||
{lastMessageFeedbackBanner}
|
||||
{userMessagesCount >= TOO_MANY_USER_MESSAGES_WARNING_COUNT ? (
|
||||
<AlertMessage
|
||||
kind={
|
||||
userMessagesCount >= TOO_MANY_USER_MESSAGES_ERROR_COUNT
|
||||
? 'error'
|
||||
: 'warning'
|
||||
}
|
||||
>
|
||||
<Trans>
|
||||
The chat is becoming long - consider creating a new chat to
|
||||
ask other questions. The AI will better analyze your game and
|
||||
request in a new chat.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : (
|
||||
subscriptionBanner
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</ScrollView>
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onSendMessage({
|
||||
userMessage: userRequestTextPerAiRequestId[aiRequestId] || '',
|
||||
});
|
||||
}}
|
||||
className={classNames({
|
||||
// Move the form up when the soft keyboard is open:
|
||||
'avoid-soft-keyboard': true,
|
||||
})}
|
||||
>
|
||||
<ColumnStackLayout
|
||||
justifyContent="stretch"
|
||||
alignItems="stretch"
|
||||
noMargin
|
||||
>
|
||||
{aiRequest.mode === 'agent' &&
|
||||
isAutoProcessingFunctionCalls &&
|
||||
(hasWorkingFunctionCalls ||
|
||||
isSending ||
|
||||
aiRequest.status === 'working') ? (
|
||||
<Paper background="dark" variant="outlined">
|
||||
<Column>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<CircularProgress variant="indeterminate" size={12} />
|
||||
<Text size="body" color="secondary" noMargin>
|
||||
<Trans>The AI is building your request.</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
<Text size="body" noMargin>
|
||||
<Link
|
||||
href={'#'}
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setAutoProcessFunctionCalls(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Pause</Trans>
|
||||
</Link>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</Paper>
|
||||
) : aiRequest.mode === 'agent' &&
|
||||
isPausedAndHasFunctionCallsToProcess ? (
|
||||
<Paper background="dark" variant="outlined">
|
||||
<Column>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<Text size="body" color="secondary" noMargin>
|
||||
<Trans>The AI agent is paused.</Trans>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
<Text size="body" noMargin>
|
||||
<Link
|
||||
href={'#'}
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setAutoProcessFunctionCalls(true);
|
||||
onProcessFunctionCalls(allFunctionCallsToProcess);
|
||||
}}
|
||||
>
|
||||
<Trans>Resume all</Trans>
|
||||
</Link>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</Paper>
|
||||
) : null}
|
||||
<CompactTextAreaFieldWithControls
|
||||
maxLength={6000}
|
||||
value={userRequestTextPerAiRequestId[aiRequestId] || ''}
|
||||
disabled={isSending || isForAnotherProject}
|
||||
errored={!!lastSendError}
|
||||
hasNeonCorner
|
||||
hasAnimatedNeonCorner={isSending}
|
||||
onChange={userRequestText =>
|
||||
setUserRequestTextPerRequestId(
|
||||
userRequestTextPerAiRequestId => ({
|
||||
...userRequestTextPerAiRequestId,
|
||||
[aiRequestId]: userRequestText,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
aiRequest.mode === 'agent'
|
||||
? isForAnotherProject
|
||||
? t`You must re-open the project to continue this chat.`
|
||||
: t`Specify something more to the AI to build`
|
||||
: t`Ask a follow up question`
|
||||
}
|
||||
rows={2}
|
||||
onSubmit={() => {
|
||||
onSendMessage({
|
||||
userMessage: userRequestTextPerAiRequestId[aiRequestId] || '',
|
||||
});
|
||||
}}
|
||||
controls={
|
||||
<Column>
|
||||
<LineStackLayout
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
disabled={
|
||||
aiRequest.status === 'working' ||
|
||||
isSending ||
|
||||
isForAnotherProject ||
|
||||
!userRequestTextPerAiRequestId[aiRequestId]
|
||||
}
|
||||
icon={<Send />}
|
||||
onClick={() => {
|
||||
onSendMessage({
|
||||
userMessage:
|
||||
userRequestTextPerAiRequestId[aiRequestId] || '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
}
|
||||
/>
|
||||
<Column noMargin alignItems="stretch">
|
||||
<LineStackLayout
|
||||
expand
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{isForAnotherProjectText || errorText || priceText}
|
||||
{errorText || isForAnotherProjectText
|
||||
? null
|
||||
: quotaOrCreditsText}
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
1081
newIDE/app/src/AiGeneration/AskAiEditorContainer.js
Normal file
1081
newIDE/app/src/AiGeneration/AskAiEditorContainer.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,25 @@
|
||||
import * as React from 'react';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import ButtonBase from '@material-ui/core/ButtonBase';
|
||||
import { Line, Column } from '../../../UI/Grid';
|
||||
import { ColumnStackLayout } from '../../../UI/Layout';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Line, Column } from '../UI/Grid';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import Text from '../UI/Text';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
getAiRequests,
|
||||
type AiRequest,
|
||||
} from '../../../Utils/GDevelopServices/Generation';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import ScrollView from '../../../UI/ScrollView';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import EmptyMessage from '../../../UI/EmptyMessage';
|
||||
import CircularProgress from '../../../UI/CircularProgress';
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import Paper from '../UI/Paper';
|
||||
import ScrollView from '../UI/ScrollView';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
import CircularProgress from '../UI/CircularProgress';
|
||||
import formatDate from 'date-fns/format';
|
||||
import DrawerTopBar from '../../../UI/DrawerTopBar';
|
||||
import PlaceholderError from '../../../UI/PlaceholderError';
|
||||
import { textEllipsisStyle } from '../../../UI/TextEllipsis';
|
||||
import DrawerTopBar from '../UI/DrawerTopBar';
|
||||
import PlaceholderError from '../UI/PlaceholderError';
|
||||
import { textEllipsisStyle } from '../UI/TextEllipsis';
|
||||
|
||||
type Props = {|
|
||||
open: boolean,
|
||||
@@ -61,8 +61,15 @@ const getFirstUserRequestText = (aiRequest: AiRequest): string => {
|
||||
if (!aiRequest.output || aiRequest.output.length === 0) return '';
|
||||
|
||||
// Find the first user message
|
||||
const userMessage = aiRequest.output.find(message => message.role === 'user');
|
||||
if (!userMessage) return '';
|
||||
const userMessage = aiRequest.output.find(
|
||||
message => message.type === 'message' && message.role === 'user'
|
||||
);
|
||||
if (
|
||||
!userMessage ||
|
||||
userMessage.type !== 'message' ||
|
||||
userMessage.role !== 'user'
|
||||
)
|
||||
return '';
|
||||
|
||||
// Extract text from user message content
|
||||
return userMessage.content
|
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import { ToolbarGroup } from '../../../UI/Toolbar';
|
||||
import AddIcon from '../../../UI/CustomSvgIcons/Add';
|
||||
import HistoryIcon from '../../../UI/CustomSvgIcons/History';
|
||||
import RaisedButton from '../../../UI/RaisedButton';
|
||||
import IconButton from '../../../UI/IconButton';
|
||||
import { ToolbarGroup } from '../UI/Toolbar';
|
||||
import AddIcon from '../UI/CustomSvgIcons/Add';
|
||||
import HistoryIcon from '../UI/CustomSvgIcons/History';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import IconButton from '../UI/IconButton';
|
||||
|
||||
type Props = {|
|
||||
onStartNewChat: () => void,
|
146
newIDE/app/src/AiGeneration/UseCreateAiProjectDialog.js
Normal file
146
newIDE/app/src/AiGeneration/UseCreateAiProjectDialog.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// @flow
|
||||
|
||||
import React from 'react';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import EmptyAndStartingPointProjects from '../ProjectCreation/EmptyAndStartingPointProjects';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import Dialog from '../UI/Dialog';
|
||||
import { type ExampleShortHeader } from '../Utils/GDevelopServices/Example';
|
||||
import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider';
|
||||
import { generateProjectName } from '../ProjectCreation/NewProjectSetupDialog';
|
||||
import { type NewProjectSetup } from '../ProjectCreation/NewProjectSetupDialog';
|
||||
import { Spacer } from '../UI/Grid';
|
||||
|
||||
type RenderCreateAiProjectDialogProps = {
|
||||
onCreateEmptyProject: (newProjectSetup: NewProjectSetup) => Promise<void>,
|
||||
onCreateProjectFromExample: (
|
||||
exampleShortHeader: ExampleShortHeader,
|
||||
newProjectSetup: NewProjectSetup,
|
||||
i18n: I18nType,
|
||||
isQuickCustomization?: boolean
|
||||
) => Promise<void>,
|
||||
};
|
||||
|
||||
type CreateAiProjectDialogProps = {
|
||||
onClose: () => void,
|
||||
onSelectExampleShortHeader: (
|
||||
exampleShortHeader: ExampleShortHeader
|
||||
) => Promise<void>,
|
||||
onSelectEmptyProject: () => Promise<void>,
|
||||
};
|
||||
|
||||
const CreateAiProjectDialog = ({
|
||||
onClose,
|
||||
onSelectExampleShortHeader,
|
||||
onSelectEmptyProject,
|
||||
}: CreateAiProjectDialogProps) => {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={<Trans>Ok! Choose a game style to start with</Trans>}
|
||||
id="ai-project-dialog"
|
||||
maxWidth="md"
|
||||
actions={[
|
||||
<FlatButton
|
||||
key="cancel"
|
||||
label={<Trans>Cancel</Trans>}
|
||||
onClick={onClose}
|
||||
/>,
|
||||
]}
|
||||
onRequestClose={onClose}
|
||||
flexColumnBody
|
||||
>
|
||||
<ColumnStackLayout noMargin>
|
||||
<Spacer />
|
||||
<EmptyAndStartingPointProjects
|
||||
onSelectExampleShortHeader={exampleShortHeader => {
|
||||
onSelectExampleShortHeader(exampleShortHeader);
|
||||
}}
|
||||
onSelectEmptyProject={() => {
|
||||
onSelectEmptyProject();
|
||||
}}
|
||||
/>
|
||||
{/* Use a spacer to avoid extra scrollbars when template tiles are hovered. */}
|
||||
<Spacer />
|
||||
</ColumnStackLayout>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type CreateAiProjectResult = 'canceled' | 'created';
|
||||
|
||||
export const useCreateAiProjectDialog = () => {
|
||||
const [createPromise, setCreatePromise] = React.useState<null | {|
|
||||
onFinished: (result: CreateAiProjectResult) => void,
|
||||
promise: Promise<CreateAiProjectResult>,
|
||||
|}>(null);
|
||||
|
||||
const createAiProject: () => Promise<CreateAiProjectResult> = React.useCallback(
|
||||
() => {
|
||||
if (createPromise) {
|
||||
return createPromise.promise;
|
||||
}
|
||||
|
||||
// Make a promise that we can resolve later from the creation dialog.
|
||||
let resolve: (result: CreateAiProjectResult) => void = () => {};
|
||||
const promise = new Promise(resolveFn => {
|
||||
resolve = resolveFn;
|
||||
});
|
||||
|
||||
setCreatePromise({
|
||||
onFinished: (result: CreateAiProjectResult) => {
|
||||
setCreatePromise(null);
|
||||
resolve(result);
|
||||
},
|
||||
promise,
|
||||
});
|
||||
return promise;
|
||||
},
|
||||
[createPromise]
|
||||
);
|
||||
|
||||
return {
|
||||
createAiProject,
|
||||
renderCreateAiProjectDialog: (props: RenderCreateAiProjectDialogProps) => {
|
||||
if (!createPromise) return null;
|
||||
|
||||
return (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<CreateAiProjectDialog
|
||||
onClose={() => {
|
||||
createPromise.onFinished('canceled');
|
||||
}}
|
||||
onSelectExampleShortHeader={async exampleShortHeader => {
|
||||
const newProjectSetup: NewProjectSetup = {
|
||||
storageProvider: UrlStorageProvider,
|
||||
saveAsLocation: null,
|
||||
dontOpenAnySceneOrProjectManager: true,
|
||||
};
|
||||
await props.onCreateProjectFromExample(
|
||||
exampleShortHeader,
|
||||
newProjectSetup,
|
||||
i18n,
|
||||
false // isQuickCustomization
|
||||
);
|
||||
createPromise.onFinished('created');
|
||||
}}
|
||||
onSelectEmptyProject={async () => {
|
||||
await props.onCreateEmptyProject({
|
||||
projectName: generateProjectName('AI starter'),
|
||||
storageProvider: UrlStorageProvider,
|
||||
saveAsLocation: null,
|
||||
dontOpenAnySceneOrProjectManager: true,
|
||||
});
|
||||
createPromise.onFinished('created');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
54
newIDE/app/src/AiGeneration/UseEnsureExtensionInstalled.js
Normal file
54
newIDE/app/src/AiGeneration/UseEnsureExtensionInstalled.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { ExtensionStoreContext } from '../AssetStore/ExtensionStore/ExtensionStoreContext';
|
||||
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
|
||||
import { installExtension } from '../AssetStore/ExtensionStore/InstallExtension';
|
||||
|
||||
type EnsureExtensionInstalledOptions = {|
|
||||
extensionName: string,
|
||||
|};
|
||||
|
||||
export const useEnsureExtensionInstalled = ({
|
||||
project,
|
||||
i18n,
|
||||
}: {|
|
||||
project: ?gdProject,
|
||||
i18n: I18nType,
|
||||
|}) => {
|
||||
const { translatedExtensionShortHeadersByName } = React.useContext(
|
||||
ExtensionStoreContext
|
||||
);
|
||||
const eventsFunctionsExtensionsState = React.useContext(
|
||||
EventsFunctionsExtensionsContext
|
||||
);
|
||||
|
||||
return {
|
||||
ensureExtensionInstalled: React.useCallback(
|
||||
async ({ extensionName }: EnsureExtensionInstalledOptions) => {
|
||||
if (!project) return;
|
||||
if (project.getCurrentPlatform().isExtensionLoaded(extensionName))
|
||||
return;
|
||||
|
||||
const extensionShortHeader =
|
||||
translatedExtensionShortHeadersByName[extensionName];
|
||||
if (!extensionShortHeader) {
|
||||
throw new Error(`Can't find extension named ${extensionName}.`);
|
||||
}
|
||||
|
||||
await installExtension(
|
||||
i18n,
|
||||
project,
|
||||
eventsFunctionsExtensionsState,
|
||||
extensionShortHeader
|
||||
);
|
||||
},
|
||||
[
|
||||
eventsFunctionsExtensionsState,
|
||||
i18n,
|
||||
project,
|
||||
translatedExtensionShortHeadersByName,
|
||||
]
|
||||
),
|
||||
};
|
||||
};
|
107
newIDE/app/src/AiGeneration/UseGenerateEvents.js
Normal file
107
newIDE/app/src/AiGeneration/UseGenerateEvents.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import { retryIfFailed } from '../Utils/RetryIfFailed';
|
||||
import { delay } from '../Utils/Delay';
|
||||
import {
|
||||
getAiGeneratedEvent,
|
||||
createAiGeneratedEvent,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
|
||||
import { type EventsGenerationResult } from '../EditorFunctions';
|
||||
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
export const useGenerateEvents = ({ project }: {| project: ?gdProject |}) => {
|
||||
const { profile, getAuthorizationHeader } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const generateEvents = React.useCallback(
|
||||
async ({
|
||||
sceneName,
|
||||
eventsDescription,
|
||||
extensionNamesList,
|
||||
objectsList,
|
||||
existingEventsAsText,
|
||||
placementHint,
|
||||
relatedAiRequestId,
|
||||
}: {|
|
||||
sceneName: string,
|
||||
eventsDescription: string,
|
||||
extensionNamesList: string,
|
||||
objectsList: string,
|
||||
existingEventsAsText: string,
|
||||
placementHint: string,
|
||||
relatedAiRequestId: string,
|
||||
|}): Promise<EventsGenerationResult> => {
|
||||
if (!project) throw new Error('No project is opened.');
|
||||
if (!profile) throw new Error('User should be authenticated.');
|
||||
|
||||
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
|
||||
const projectSpecificExtensionsSummaryJson = JSON.stringify(
|
||||
simplifiedProjectBuilder.getProjectSpecificExtensionsSummary(project)
|
||||
);
|
||||
|
||||
const createResult = await retryIfFailed({ times: 2 }, () =>
|
||||
createAiGeneratedEvent(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
partialGameProjectJson: JSON.stringify(
|
||||
simplifiedProjectBuilder.getSimplifiedProject(project, {
|
||||
scopeToScene: sceneName,
|
||||
}),
|
||||
null,
|
||||
2
|
||||
),
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
sceneName,
|
||||
eventsDescription,
|
||||
extensionNamesList,
|
||||
objectsList,
|
||||
existingEventsAsText,
|
||||
placementHint,
|
||||
relatedAiRequestId,
|
||||
})
|
||||
);
|
||||
|
||||
if (!createResult.creationSucceeded) {
|
||||
return {
|
||||
generationCompleted: false,
|
||||
errorMessage: createResult.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
let remainingAttempts = 50;
|
||||
let aiGeneratedEvent = createResult.aiGeneratedEvent;
|
||||
while (aiGeneratedEvent.status === 'working') {
|
||||
remainingAttempts--;
|
||||
await delay(1000);
|
||||
|
||||
try {
|
||||
aiGeneratedEvent = await getAiGeneratedEvent(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiGeneratedEventId: aiGeneratedEvent.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Error while checking status of AI generated event - continuing...',
|
||||
error
|
||||
);
|
||||
}
|
||||
if (remainingAttempts <= 0) {
|
||||
return {
|
||||
generationCompleted: false,
|
||||
errorMessage:
|
||||
'Event generation started but failed to complete in time.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { generationCompleted: true, aiGeneratedEvent };
|
||||
},
|
||||
[getAuthorizationHeader, project, profile]
|
||||
);
|
||||
|
||||
return { generateEvents };
|
||||
};
|
85
newIDE/app/src/AiGeneration/UseSearchAndInstallAsset.js
Normal file
85
newIDE/app/src/AiGeneration/UseSearchAndInstallAsset.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type AssetSearchAndInstallOptions,
|
||||
type AssetSearchAndInstallResult,
|
||||
} from '../EditorFunctions';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import {
|
||||
createAssetSearch,
|
||||
type AssetSearch,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import { retryIfFailed } from '../Utils/RetryIfFailed';
|
||||
import { useInstallAsset } from '../AssetStore/NewObjectDialog';
|
||||
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
|
||||
|
||||
export const useSearchAndInstallAsset = ({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
onExtensionInstalled,
|
||||
}: {|
|
||||
project: gdProject | null,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|}) => {
|
||||
const { profile, getAuthorizationHeader } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
const installAsset = useInstallAsset({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
onExtensionInstalled,
|
||||
});
|
||||
|
||||
return {
|
||||
searchAndInstallAsset: React.useCallback(
|
||||
async ({
|
||||
scene,
|
||||
objectName,
|
||||
...assetSearchOptions
|
||||
}: AssetSearchAndInstallOptions): Promise<AssetSearchAndInstallResult> => {
|
||||
if (!profile) throw new Error('User should be authenticated.');
|
||||
|
||||
const assetSearch: AssetSearch = await retryIfFailed({ times: 2 }, () =>
|
||||
createAssetSearch(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
...assetSearchOptions,
|
||||
})
|
||||
);
|
||||
if (!assetSearch.results || assetSearch.results.length === 0) {
|
||||
return {
|
||||
status: 'nothing-found',
|
||||
message: 'No assets found.',
|
||||
createdObjects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// In the future, we could ask the user to select the asset they want to use.
|
||||
// For now, we just return the first asset.
|
||||
const chosenResult = assetSearch.results[0];
|
||||
if (!chosenResult) throw new Error('No asset found.');
|
||||
|
||||
const installOutput = await installAsset({
|
||||
assetShortHeader: chosenResult.asset,
|
||||
objectsContainer: scene.getObjects(),
|
||||
requestedObjectName: objectName,
|
||||
});
|
||||
|
||||
if (!installOutput) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Asset found but failed to install asset.',
|
||||
createdObjects: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'asset-installed',
|
||||
message: 'Asset installed successfully.',
|
||||
createdObjects: installOutput.createdObjects,
|
||||
};
|
||||
},
|
||||
[installAsset, profile, getAuthorizationHeader]
|
||||
),
|
||||
};
|
||||
};
|
@@ -49,6 +49,7 @@ type Props = {|
|
||||
addedAssetIds: Set<string>,
|
||||
onClose: () => void,
|
||||
onAssetsAdded: (createdObjects: gdObject[]) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
project: gdProject,
|
||||
objectsContainer: ?gdObjectsContainer,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
@@ -61,6 +62,7 @@ const AssetPackInstallDialog = ({
|
||||
addedAssetIds,
|
||||
onClose,
|
||||
onAssetsAdded,
|
||||
onExtensionInstalled,
|
||||
project,
|
||||
objectsContainer,
|
||||
resourceManagementProps,
|
||||
@@ -176,6 +178,7 @@ const AssetPackInstallDialog = ({
|
||||
shouldUpdateExtension: extensionUpdateAction === 'update',
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled,
|
||||
});
|
||||
|
||||
// Use a pool to avoid installing an unbounded amount of assets at the same time.
|
||||
@@ -253,6 +256,7 @@ const AssetPackInstallDialog = ({
|
||||
eventsFunctionsExtensionsState,
|
||||
resourceManagementProps,
|
||||
onAssetsAdded,
|
||||
onExtensionInstalled,
|
||||
installPrivateAsset,
|
||||
targetObjectsContainer,
|
||||
targetObjectFolderOrObjectWithContext,
|
||||
|
@@ -29,6 +29,7 @@ type Props = {|
|
||||
onClose: ({ swappingDone: boolean }) => void,
|
||||
// Use minimal UI to hide filters & the details page (useful for Quick Customization)
|
||||
minimalUI?: boolean,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
function AssetSwappingDialog({
|
||||
@@ -40,6 +41,7 @@ function AssetSwappingDialog({
|
||||
resourceManagementProps,
|
||||
onClose,
|
||||
minimalUI,
|
||||
onExtensionInstalled,
|
||||
}: Props) {
|
||||
const shopNavigationState = React.useContext(AssetStoreNavigatorContext);
|
||||
const { openedAssetShortHeader } = shopNavigationState.getCurrentPage();
|
||||
@@ -50,8 +52,8 @@ function AssetSwappingDialog({
|
||||
] = React.useState<boolean>(false);
|
||||
const installAsset = useInstallAsset({
|
||||
project,
|
||||
objectsContainer,
|
||||
resourceManagementProps,
|
||||
onExtensionInstalled,
|
||||
});
|
||||
const { showAlert } = useAlertDialog();
|
||||
|
||||
@@ -73,7 +75,10 @@ function AssetSwappingDialog({
|
||||
|
||||
setIsAssetBeingInstalled(true);
|
||||
try {
|
||||
const installAssetOutput = await installAsset(openedAssetShortHeader);
|
||||
const installAssetOutput = await installAsset({
|
||||
assetShortHeader: openedAssetShortHeader,
|
||||
objectsContainer,
|
||||
});
|
||||
if (!installAssetOutput) {
|
||||
throw new Error('Failed to install asset');
|
||||
}
|
||||
|
@@ -25,7 +25,7 @@ type Props = {|
|
||||
project: gdProject,
|
||||
onClose: () => void,
|
||||
onInstallExtension: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
onCreateNew?: () => void,
|
||||
|};
|
||||
|
||||
@@ -84,7 +84,7 @@ const ExtensionsSearchDialog = ({
|
||||
|
||||
if (installedOrImportedExtensionName) {
|
||||
setExtensionWasInstalled(true);
|
||||
onExtensionInstalled(installedOrImportedExtensionName);
|
||||
onExtensionInstalled([installedOrImportedExtensionName]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@@ -10,6 +10,7 @@ import { addSerializedExtensionsToProject } from '../InstallAsset';
|
||||
import { type EventsFunctionsExtensionsState } from '../../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
|
||||
import { t } from '@lingui/macro';
|
||||
import Window from '../../Utils/Window';
|
||||
import { retryIfFailed } from '../../Utils/RetryIfFailed';
|
||||
|
||||
/**
|
||||
* Download and add the extension in the project.
|
||||
@@ -21,7 +22,9 @@ export const installExtension = async (
|
||||
extensionShortHeader: ExtensionShortHeader | BehaviorShortHeader
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const serializedExtension = await getExtension(extensionShortHeader);
|
||||
const serializedExtension = await retryIfFailed({ times: 2 }, () =>
|
||||
getExtension(extensionShortHeader)
|
||||
);
|
||||
await addSerializedExtensionsToProject(
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
|
@@ -29,34 +29,6 @@ import {
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const toPascalCase = (str: string) => {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.replace(/^[^A-Za-z0-9]*|[^A-Za-z0-9]*$/g, '$')
|
||||
.replace(/[^A-Za-z0-9]+/g, '$')
|
||||
.replace(/([a-z])([A-Z])/g, function(m, a, b) {
|
||||
return a + '$' + b;
|
||||
})
|
||||
.toLowerCase()
|
||||
.replace(/(\$)(\w?)/g, function(m, a, b) {
|
||||
return b.toUpperCase();
|
||||
});
|
||||
};
|
||||
|
||||
export const sanitizeObjectName = (objectName: string) => {
|
||||
const trimmedObjectName = objectName.trim();
|
||||
if (!trimmedObjectName) return 'UnnamedObject';
|
||||
|
||||
const pascalCaseName = toPascalCase(trimmedObjectName);
|
||||
|
||||
let prefixedObjectName = pascalCaseName;
|
||||
if (prefixedObjectName[0] >= '0' && prefixedObjectName[0] <= '9') {
|
||||
prefixedObjectName = '_' + prefixedObjectName;
|
||||
}
|
||||
|
||||
return prefixedObjectName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the specified resource to the resources manager, avoiding to duplicate
|
||||
* if it was already added.
|
||||
@@ -156,6 +128,7 @@ export type InstallAssetArgs = {|
|
||||
project: gdProject,
|
||||
objectsContainer: gdObjectsContainer,
|
||||
targetObjectFolderOrObject?: ?gdObjectFolderOrObject,
|
||||
requestedObjectName?: string,
|
||||
|};
|
||||
|
||||
const findVariant = (
|
||||
@@ -180,6 +153,7 @@ export const addAssetToProject = async ({
|
||||
project,
|
||||
objectsContainer,
|
||||
targetObjectFolderOrObject,
|
||||
requestedObjectName,
|
||||
}: InstallAssetArgs): Promise<InstallAssetOutput> => {
|
||||
const objectNewNames = {};
|
||||
const resourceNewNames = {};
|
||||
@@ -272,8 +246,10 @@ export const addAssetToProject = async ({
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the object
|
||||
const originalName = sanitizeObjectName(objectAsset.object.name);
|
||||
// Insert the object.
|
||||
const originalName = gd.Project.getSafeName(
|
||||
requestedObjectName || objectAsset.object.name
|
||||
);
|
||||
const newName = newNameGenerator(originalName, name =>
|
||||
objectsContainer.hasObjectNamed(name)
|
||||
);
|
||||
@@ -405,6 +381,7 @@ export type InstallRequiredExtensionsArgs = {|
|
||||
shouldUpdateExtension: boolean,
|
||||
eventsFunctionsExtensionsState: EventsFunctionsExtensionsState,
|
||||
project: gdProject,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
export const installRequiredExtensions = async ({
|
||||
@@ -412,6 +389,7 @@ export const installRequiredExtensions = async ({
|
||||
shouldUpdateExtension,
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled,
|
||||
}: InstallRequiredExtensionsArgs): Promise<void> => {
|
||||
const {
|
||||
requiredExtensionShortHeaders,
|
||||
@@ -441,6 +419,9 @@ export const installRequiredExtensions = async ({
|
||||
project,
|
||||
serializedExtensions
|
||||
);
|
||||
onExtensionInstalled(
|
||||
neededExtensions.map(extensionShortHeader => extensionShortHeader.name)
|
||||
);
|
||||
|
||||
const stillMissingExtensions = filterMissingExtensions(
|
||||
gd,
|
||||
|
@@ -4,7 +4,6 @@ import {
|
||||
addSerializedExtensionsToProject,
|
||||
getRequiredExtensionsFromAsset,
|
||||
installRequiredExtensions,
|
||||
sanitizeObjectName,
|
||||
installPublicAsset,
|
||||
checkRequiredExtensionsUpdate,
|
||||
checkRequiredExtensionsUpdateForAssets,
|
||||
@@ -43,19 +42,6 @@ Asset.getPublicAsset = jest.fn();
|
||||
const mockFn = (fn: Function): JestMockFn<any, any> => fn;
|
||||
|
||||
describe('InstallAsset', () => {
|
||||
test('sanitizeObjectName', () => {
|
||||
expect(sanitizeObjectName('')).toBe('UnnamedObject');
|
||||
expect(sanitizeObjectName('HelloWorld')).toBe('HelloWorld');
|
||||
expect(sanitizeObjectName('Hello World')).toBe('HelloWorld');
|
||||
expect(sanitizeObjectName('hello world')).toBe('HelloWorld');
|
||||
expect(sanitizeObjectName('hello world12')).toBe('HelloWorld12');
|
||||
expect(sanitizeObjectName('12 hello world')).toBe('_12HelloWorld');
|
||||
expect(sanitizeObjectName('/-=hello/-=world/-=')).toBe('HelloWorld');
|
||||
expect(sanitizeObjectName(' hello/-=world/-=')).toBe('HelloWorld');
|
||||
expect(sanitizeObjectName('9hello/-=world/-=')).toBe('_9helloWorld');
|
||||
expect(sanitizeObjectName(' 9hello/-=world/-=')).toBe('_9helloWorld');
|
||||
});
|
||||
|
||||
describe('addAssetToProject', () => {
|
||||
it('installs an object asset in the project, without renaming it if not needed', async () => {
|
||||
const { project } = makeTestProject(gd);
|
||||
@@ -1048,6 +1034,7 @@ describe('InstallAsset', () => {
|
||||
shouldUpdateExtension: true,
|
||||
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled: () => {},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
// It's just because the mock doesn't reloadProjectEventsFunctionsExtensions.
|
||||
@@ -1083,6 +1070,7 @@ describe('InstallAsset', () => {
|
||||
shouldUpdateExtension: true,
|
||||
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled: () => {},
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: 'These extensions could not be installed: Flash',
|
||||
@@ -1123,6 +1111,7 @@ describe('InstallAsset', () => {
|
||||
shouldUpdateExtension: true,
|
||||
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled: () => {},
|
||||
});
|
||||
|
||||
// No extensions fetched because the extension is already installed.
|
||||
|
@@ -172,14 +172,14 @@ export const useFetchAssets = () => {
|
||||
|
||||
export const useInstallAsset = ({
|
||||
project,
|
||||
objectsContainer,
|
||||
targetObjectFolderOrObjectWithContext,
|
||||
resourceManagementProps,
|
||||
onExtensionInstalled,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
objectsContainer: gdObjectsContainer,
|
||||
project: gdProject | null,
|
||||
targetObjectFolderOrObjectWithContext?: ?ObjectFolderOrObjectWithContext,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|}) => {
|
||||
const shopNavigationState = React.useContext(AssetStoreNavigatorContext);
|
||||
const { openedAssetPack } = shopNavigationState.getCurrentPage();
|
||||
@@ -196,9 +196,18 @@ export const useInstallAsset = ({
|
||||
resourceManagementProps.canInstallPrivateAsset
|
||||
);
|
||||
|
||||
return async (
|
||||
assetShortHeader: AssetShortHeader
|
||||
): Promise<InstallAssetOutput | null> => {
|
||||
return async ({
|
||||
assetShortHeader,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
}: {|
|
||||
assetShortHeader: AssetShortHeader,
|
||||
objectsContainer: gdObjectsContainer,
|
||||
requestedObjectName?: string,
|
||||
|}): Promise<InstallAssetOutput | null> => {
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (await showProjectNeedToBeSaved(assetShortHeader)) {
|
||||
return null;
|
||||
@@ -235,6 +244,7 @@ export const useInstallAsset = ({
|
||||
shouldUpdateExtension: extensionUpdateAction === 'update',
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled,
|
||||
});
|
||||
const isPrivate = isPrivateAsset(assetShortHeader);
|
||||
const installOutput = isPrivate
|
||||
@@ -242,6 +252,7 @@ export const useInstallAsset = ({
|
||||
asset,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject:
|
||||
targetObjectFolderOrObjectWithContext &&
|
||||
!targetObjectFolderOrObjectWithContext.global
|
||||
@@ -252,6 +263,7 @@ export const useInstallAsset = ({
|
||||
asset,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject:
|
||||
targetObjectFolderOrObjectWithContext &&
|
||||
!targetObjectFolderOrObjectWithContext.global
|
||||
@@ -300,6 +312,7 @@ type Props = {|
|
||||
onCreateNewObject: (type: string) => void,
|
||||
onObjectsAddedFromAssets: (Array<gdObject>) => void,
|
||||
targetObjectFolderOrObjectWithContext?: ?ObjectFolderOrObjectWithContext,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
function NewObjectDialog({
|
||||
@@ -312,6 +325,7 @@ function NewObjectDialog({
|
||||
onCreateNewObject,
|
||||
onObjectsAddedFromAssets,
|
||||
targetObjectFolderOrObjectWithContext,
|
||||
onExtensionInstalled,
|
||||
}: Props) {
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
const {
|
||||
@@ -367,9 +381,9 @@ function NewObjectDialog({
|
||||
const showExtensionUpdateConfirmation = useExtensionUpdateAlertDialog();
|
||||
const installAsset = useInstallAsset({
|
||||
project,
|
||||
objectsContainer,
|
||||
resourceManagementProps,
|
||||
targetObjectFolderOrObjectWithContext,
|
||||
onExtensionInstalled,
|
||||
});
|
||||
|
||||
const onInstallAsset = React.useCallback(
|
||||
@@ -377,13 +391,16 @@ function NewObjectDialog({
|
||||
if (!assetShortHeader) return false;
|
||||
|
||||
setIsAssetBeingInstalled(true);
|
||||
const installAssetOutput = await installAsset(assetShortHeader);
|
||||
const installAssetOutput = await installAsset({
|
||||
assetShortHeader,
|
||||
objectsContainer,
|
||||
});
|
||||
setIsAssetBeingInstalled(false);
|
||||
if (installAssetOutput)
|
||||
onObjectsAddedFromAssets(installAssetOutput.createdObjects);
|
||||
return !!installAssetOutput;
|
||||
},
|
||||
[installAsset, onObjectsAddedFromAssets]
|
||||
[installAsset, onObjectsAddedFromAssets, objectsContainer]
|
||||
);
|
||||
|
||||
const onInstallEmptyCustomObject = React.useCallback(
|
||||
@@ -425,6 +442,7 @@ function NewObjectDialog({
|
||||
shouldUpdateExtension: extensionUpdateAction === 'update',
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
onExtensionInstalled,
|
||||
});
|
||||
|
||||
onCreateNewObject(enumeratedObjectMetadata.name);
|
||||
@@ -446,6 +464,7 @@ function NewObjectDialog({
|
||||
showExtensionUpdateConfirmation,
|
||||
eventsFunctionsExtensionsState,
|
||||
showAlert,
|
||||
onExtensionInstalled,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -660,6 +679,7 @@ function NewObjectDialog({
|
||||
targetObjectFolderOrObjectWithContext={
|
||||
targetObjectFolderOrObjectWithContext
|
||||
}
|
||||
onExtensionInstalled={onExtensionInstalled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@@ -656,6 +656,7 @@ const PrivateAssetPackInformationPage = ({
|
||||
analyticsMetadata: {
|
||||
reason: 'Claim asset pack',
|
||||
recommendedPlanId: 'gdevelop_gold',
|
||||
placementId: 'claim-asset-pack',
|
||||
},
|
||||
filter: 'individual',
|
||||
})
|
||||
|
@@ -114,6 +114,7 @@ const PrivateAssetsAuthorizationProvider = ({ children }: Props) => {
|
||||
asset,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject,
|
||||
}: InstallAssetArgs): Promise<?InstallAssetOutput> => {
|
||||
if (!profile) {
|
||||
@@ -134,6 +135,7 @@ const PrivateAssetsAuthorizationProvider = ({ children }: Props) => {
|
||||
asset: assetWithAuthorizedResourceUrls,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject,
|
||||
});
|
||||
};
|
||||
|
@@ -74,3 +74,12 @@ export const filterEnumeratedBehaviorMetadata = (
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const isBehaviorDefaultCapability = (
|
||||
behaviorMetadata: gdBehaviorMetadata
|
||||
) => {
|
||||
return (
|
||||
behaviorMetadata.getName().includes('Capability') ||
|
||||
behaviorMetadata.getName() === 'Scene3D::Base3DBehavior'
|
||||
);
|
||||
};
|
||||
|
@@ -32,7 +32,7 @@ type Props = {|
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onChoose: (type: string, defaultName: string) => void,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
export default function NewBehaviorDialog({
|
||||
@@ -190,7 +190,7 @@ export default function NewBehaviorDialog({
|
||||
behaviorShortHeader
|
||||
);
|
||||
if (wasExtensionInstalled) {
|
||||
onExtensionInstalled(behaviorShortHeader.extensionName);
|
||||
onExtensionInstalled([behaviorShortHeader.extensionName]);
|
||||
}
|
||||
return wasExtensionInstalled;
|
||||
} finally {
|
||||
|
@@ -34,7 +34,8 @@ import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu';
|
||||
import Trash from '../UI/CustomSvgIcons/Trash';
|
||||
import Add from '../UI/CustomSvgIcons/Add';
|
||||
import { mapVector } from '../Utils/MapFor';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
@@ -324,7 +325,7 @@ export const useManageObjectBehaviors = ({
|
||||
onSizeUpdated?: ?() => void,
|
||||
onBehaviorsUpdated?: ?() => void,
|
||||
onUpdateBehaviorsSharedData: () => void,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
}): UseManageBehaviorsState => {
|
||||
const [
|
||||
justAddedBehaviorName,
|
||||
@@ -622,7 +623,7 @@ type Props = {|
|
||||
extensionName: string,
|
||||
behaviorName: string
|
||||
) => Promise<void>,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
isListLocked: boolean,
|
||||
|};
|
||||
|
||||
|
@@ -174,6 +174,7 @@ const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
|
||||
analyticsMetadata: {
|
||||
reason: 'Unlock course chapter',
|
||||
recommendedPlanId: 'gdevelop_silver',
|
||||
placementId: 'unlock-course-chapter',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
538
newIDE/app/src/EditorFunctions/ApplyEventsChanges.js
Normal file
538
newIDE/app/src/EditorFunctions/ApplyEventsChanges.js
Normal file
@@ -0,0 +1,538 @@
|
||||
// @flow
|
||||
import { unserializeFromJSObject } from '../Utils/Serializer';
|
||||
import {
|
||||
type AiGeneratedEventChange,
|
||||
type AiGeneratedEventUndeclaredVariable,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import { mapFor } from '../Utils/MapFor';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
/**
|
||||
* Parses an event path string (e.g., "event-0.1.2") into an array of 0-based indices (e.g., [0, 1, 2]).
|
||||
* Throws an error for invalid formats or non-positive indices.
|
||||
*/
|
||||
const parseEventPath = (pathString: string): Array<number> => {
|
||||
const originalPathString = pathString;
|
||||
if (!pathString.startsWith('event-')) {
|
||||
// Fallback for paths that might not have the "event-" prefix, like "1.2.3"
|
||||
// This is a lenient parsing, primary expectation is "event-" prefix.
|
||||
const partsNoPrefix = pathString.split('.');
|
||||
if (
|
||||
partsNoPrefix.length > 0 &&
|
||||
partsNoPrefix.every(s => s !== '' && !isNaN(parseInt(s, 10)))
|
||||
) {
|
||||
console.warn(
|
||||
`Event path string "${originalPathString}" does not start with "event-". Parsed as direct indices.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid event path string format: "${originalPathString}". Expected "event-X.Y.Z" or "X.Y.Z".`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pathString = pathString.substring('event-'.length);
|
||||
}
|
||||
|
||||
const parts = pathString.split('.');
|
||||
if (
|
||||
parts.length === 0 ||
|
||||
parts.some(s => s === '' || isNaN(parseInt(s, 10)))
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid event path string content: "${originalPathString}". Ensure numbers are separated by dots.`
|
||||
);
|
||||
}
|
||||
return parts.map(s => {
|
||||
const num = parseInt(s, 10);
|
||||
if (num < 0) {
|
||||
throw new Error(
|
||||
`Event path indices must be positive in string "${originalPathString}", but found ${num}.`
|
||||
);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigates an event tree to find the parent EventsList and the 0-based index
|
||||
* for an event targeted by the given path.
|
||||
*/
|
||||
const getParentListAndIndex = (
|
||||
rootEventsList: gdEventsList,
|
||||
path: Array<number>,
|
||||
operationTypeForErrorMessage: 'access' | 'insertion'
|
||||
): { parentList: gdEventsList, eventIndexInParentList: number } => {
|
||||
if (path.length === 0) {
|
||||
throw new Error('Path cannot be empty for getParentListAndIndex.');
|
||||
}
|
||||
|
||||
let currentList = rootEventsList;
|
||||
const pathForErrorMessage = path.join('.');
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const eventIndex = path[i];
|
||||
if (eventIndex < 0 || eventIndex >= currentList.getEventsCount()) {
|
||||
throw new Error(
|
||||
`Invalid event path: index ${eventIndex} out of bounds at depth ${i +
|
||||
1} (max: ${currentList.getEventsCount() -
|
||||
1}). Path: ${pathForErrorMessage}`
|
||||
);
|
||||
}
|
||||
const event = currentList.getEventAt(eventIndex);
|
||||
if (!event.canHaveSubEvents()) {
|
||||
throw new Error(
|
||||
`Event at path segment ${i +
|
||||
1} (index ${eventIndex}) cannot have sub-events. Path: ${pathForErrorMessage}`
|
||||
);
|
||||
}
|
||||
currentList = event.getSubEvents();
|
||||
}
|
||||
|
||||
const finalIndex = path[path.length - 1];
|
||||
if (finalIndex < 0) {
|
||||
throw new Error(
|
||||
`Invalid event path: final index ${finalIndex} is negative. Path: ${pathForErrorMessage}.`
|
||||
);
|
||||
}
|
||||
|
||||
// For insertion, index can be equal to count (to append). For access, it must be less than count.
|
||||
if (
|
||||
operationTypeForErrorMessage === 'insertion' &&
|
||||
finalIndex > currentList.getEventsCount()
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid event path for insertion: final index ${finalIndex} is out of bounds. Max allowed for insertion: ${currentList.getEventsCount()}. Path: ${pathForErrorMessage}`
|
||||
);
|
||||
} else if (
|
||||
operationTypeForErrorMessage === 'access' &&
|
||||
finalIndex >= currentList.getEventsCount()
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid event path for access: final index ${finalIndex} is out of bounds. Max allowed for access: ${currentList.getEventsCount() -
|
||||
1}. Path: ${pathForErrorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
return { parentList: currentList, eventIndexInParentList: finalIndex };
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves an event at a specific path from a root EventsList.
|
||||
*/
|
||||
const getEventByPath = (
|
||||
rootEventsList: gdEventsList,
|
||||
path: Array<number>
|
||||
): gdBaseEvent => {
|
||||
const { parentList, eventIndexInParentList } = getParentListAndIndex(
|
||||
rootEventsList,
|
||||
path,
|
||||
'access'
|
||||
);
|
||||
// Bounds check already done by getParentListAndIndex for 'access'
|
||||
return parentList.getEventAt(eventIndexInParentList);
|
||||
};
|
||||
|
||||
type EventOperationType = 'delete' | 'insert' | 'insertAsSub';
|
||||
type EventOperation = {|
|
||||
type: EventOperationType,
|
||||
path: Array<number>,
|
||||
eventsToInsert?: gdEventsList,
|
||||
|};
|
||||
|
||||
const comparePathsReverseLexicographically = (
|
||||
p1: Array<number>,
|
||||
p2: Array<number>
|
||||
): number => {
|
||||
const maxLength = Math.max(p1.length, p2.length);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const val1 = i < p1.length ? p1[i] : -1;
|
||||
const val2 = i < p2.length ? p2[i] : -1;
|
||||
if (val1 > val2) return -1;
|
||||
if (val1 < val2) return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const applyEventsChanges = (
|
||||
project: gdProject,
|
||||
sceneEvents: gdEventsList,
|
||||
eventOperationsInput: Array<AiGeneratedEventChange>,
|
||||
aiGeneratedEventId: string
|
||||
): void => {
|
||||
const operations: Array<EventOperation> = [];
|
||||
|
||||
eventOperationsInput.forEach(change => {
|
||||
const { operationName, operationTargetEvent, generatedEvents } = change;
|
||||
let parsedPath: Array<number> | null = null;
|
||||
let localEventsToInsert: gdEventsList | null = null;
|
||||
|
||||
try {
|
||||
if (operationTargetEvent) {
|
||||
parsedPath = parseEventPath(operationTargetEvent);
|
||||
} else if (operationName !== 'insert_at_end') {
|
||||
// Path is generally required, except for 'insert_at_end'.
|
||||
console.warn(
|
||||
`Skipping operation "${operationName}" due to missing operationTargetEvent path.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (generatedEvents && operationName !== 'delete_event') {
|
||||
const eventsListContent = JSON.parse(generatedEvents);
|
||||
localEventsToInsert = new gd.EventsList();
|
||||
unserializeFromJSObject(
|
||||
localEventsToInsert,
|
||||
eventsListContent,
|
||||
'unserializeFrom',
|
||||
project
|
||||
);
|
||||
if (localEventsToInsert.isEmpty()) {
|
||||
console.warn(
|
||||
`Generated events for operation "${operationName}" (path: ${operationTargetEvent ||
|
||||
'N/A'}) are empty. Insertion might not add any events.`
|
||||
);
|
||||
}
|
||||
mapFor(0, localEventsToInsert.getEventsCount(), i => {
|
||||
if (!localEventsToInsert) return;
|
||||
|
||||
const event = localEventsToInsert.getEventAt(i);
|
||||
event.setAiGeneratedEventId(aiGeneratedEventId);
|
||||
});
|
||||
}
|
||||
|
||||
switch (operationName) {
|
||||
case 'insert_and_replace_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "insert_and_replace_event" due to missing or invalid path.`
|
||||
);
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
return;
|
||||
}
|
||||
operations.push({ type: 'delete', path: parsedPath });
|
||||
operations.push({
|
||||
type: 'insert',
|
||||
path: parsedPath,
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
// localEventsToInsert is now "owned" by the 'insert' operation,
|
||||
// it should not be deleted here in the switch case.
|
||||
break;
|
||||
case 'insert_before_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "insert_before_event" due to missing or invalid path.`
|
||||
);
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
return;
|
||||
}
|
||||
operations.push({
|
||||
type: 'insert',
|
||||
path: parsedPath,
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
break;
|
||||
case 'insert_as_sub_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "insert_as_sub_event" due to missing or invalid path.`
|
||||
);
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
return;
|
||||
}
|
||||
operations.push({
|
||||
type: 'insertAsSub',
|
||||
path: parsedPath, // This path is to the parent event
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
break;
|
||||
case 'delete_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "delete_event" due to missing or invalid path.`
|
||||
);
|
||||
// No localEventsToInsert expected or created for delete_event.
|
||||
return;
|
||||
}
|
||||
// Ensure no events were accidentally parsed for delete.
|
||||
if (localEventsToInsert) {
|
||||
console.warn(
|
||||
'Internal warning: localEventsToInsert was populated for a "delete_event". Cleaning up.'
|
||||
);
|
||||
localEventsToInsert.delete();
|
||||
}
|
||||
operations.push({ type: 'delete', path: parsedPath });
|
||||
break;
|
||||
case 'insert_at_end':
|
||||
// Path for insert_at_end is synthetic, representing the end of the root list.
|
||||
operations.push({
|
||||
type: 'insert',
|
||||
path: [sceneEvents.getEventsCount()],
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`Unknown operationName: "${operationName}". Skipping operation.`
|
||||
);
|
||||
// Clean up localEventsToInsert if it was created for an unknown operation
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Error processing event change (operation: "${operationName}", path: "${operationTargetEvent ||
|
||||
'N/A'}"): ${e.message}. Skipping this change.`
|
||||
);
|
||||
// Ensure cleanup if parsing/unserialization failed mid-way
|
||||
if (localEventsToInsert) {
|
||||
localEventsToInsert.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
operations.sort((opA, opB) => {
|
||||
const pathComparison = comparePathsReverseLexicographically(
|
||||
opA.path,
|
||||
opB.path
|
||||
);
|
||||
if (pathComparison !== 0) return pathComparison;
|
||||
if (opA.type === 'delete' && opB.type !== 'delete') return -1;
|
||||
if (opA.type !== 'delete' && opB.type === 'delete') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
operations.forEach(op => {
|
||||
const pathForLog = op.path.join('.');
|
||||
try {
|
||||
if (op.type === 'delete') {
|
||||
const { parentList, eventIndexInParentList } = getParentListAndIndex(
|
||||
sceneEvents,
|
||||
op.path,
|
||||
'access' // Deleting an existing event, so 'access'
|
||||
);
|
||||
// Check already done by getParentListAndIndex for 'access'
|
||||
parentList.removeEventAt(eventIndexInParentList);
|
||||
} else if (op.type === 'insert') {
|
||||
const {
|
||||
parentList,
|
||||
eventIndexInParentList: insertionIndex,
|
||||
} = getParentListAndIndex(
|
||||
sceneEvents,
|
||||
op.path,
|
||||
'insertion' // Path is for insertion point
|
||||
);
|
||||
// Check already done by getParentListAndIndex for 'insertion'
|
||||
if (op.eventsToInsert && !op.eventsToInsert.isEmpty()) {
|
||||
parentList.insertEvents(
|
||||
op.eventsToInsert,
|
||||
0,
|
||||
op.eventsToInsert.getEventsCount(),
|
||||
insertionIndex
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`Insert operation for path [${pathForLog}] skipped: no events to insert or events list is empty.`
|
||||
);
|
||||
}
|
||||
} else if (op.type === 'insertAsSub') {
|
||||
// op.path is the path to the PARENT event
|
||||
const parentEvent = getEventByPath(sceneEvents, op.path);
|
||||
if (!parentEvent.canHaveSubEvents()) {
|
||||
console.warn(
|
||||
`Cannot insert sub-events: Event at path [${pathForLog}] does not support sub-events. Skipping.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const subEventsList = parentEvent.getSubEvents();
|
||||
if (op.eventsToInsert && !op.eventsToInsert.isEmpty()) {
|
||||
subEventsList.insertEvents(
|
||||
op.eventsToInsert,
|
||||
0,
|
||||
op.eventsToInsert.getEventsCount(),
|
||||
subEventsList.getEventsCount() // Insert at the end of sub-events
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`InsertAsSub operation for parent path [${pathForLog}] skipped: no events to insert or events list is empty.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error applying event operation type ${
|
||||
op.type
|
||||
} for path [${pathForLog}]:`,
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
// Clean up the gd.EventsList associated with this operation, if any.
|
||||
if (op.eventsToInsert) {
|
||||
op.eventsToInsert.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const addUndeclaredVariables = ({
|
||||
project,
|
||||
scene,
|
||||
undeclaredVariables,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
scene: gdLayout,
|
||||
undeclaredVariables: Array<AiGeneratedEventUndeclaredVariable>,
|
||||
|}) => {
|
||||
undeclaredVariables.forEach(variable => {
|
||||
const { name, type, requiredScope } = variable;
|
||||
let newVariable = null;
|
||||
if (requiredScope === 'global') {
|
||||
if (!project.getVariables().has(name)) {
|
||||
newVariable = project.getVariables().insertNew(name, 0);
|
||||
}
|
||||
} else if (requiredScope === 'scene' || requiredScope === 'none') {
|
||||
if (!scene.getVariables().has(name)) {
|
||||
newVariable = scene.getVariables().insertNew(name, 0);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Unknown requiredScope for undeclared variable: ${name}. Skipping.`
|
||||
);
|
||||
}
|
||||
|
||||
if (newVariable && type) {
|
||||
const lowerCaseType = type.toLowerCase();
|
||||
newVariable.castTo(
|
||||
lowerCaseType === 'string'
|
||||
? 'String'
|
||||
: lowerCaseType === 'boolean'
|
||||
? 'Boolean'
|
||||
: lowerCaseType === 'array'
|
||||
? 'Array'
|
||||
: lowerCaseType === 'structure'
|
||||
? 'Structure'
|
||||
: 'Number'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const addObjectUndeclaredVariables = ({
|
||||
project,
|
||||
scene,
|
||||
objectName,
|
||||
undeclaredVariables,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
scene: gdLayout,
|
||||
objectName: string,
|
||||
undeclaredVariables: Array<AiGeneratedEventUndeclaredVariable>,
|
||||
|}) => {
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
|
||||
project,
|
||||
scene
|
||||
);
|
||||
|
||||
const setupVariable = (variable: gdVariable, type: string | null) => {
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const lowerCaseType = type.toLowerCase();
|
||||
variable.castTo(
|
||||
lowerCaseType === 'string'
|
||||
? 'String'
|
||||
: lowerCaseType === 'boolean'
|
||||
? 'Boolean'
|
||||
: lowerCaseType === 'array'
|
||||
? 'Array'
|
||||
: lowerCaseType === 'structure'
|
||||
? 'Structure'
|
||||
: 'Number'
|
||||
);
|
||||
};
|
||||
|
||||
const addVariableForObjectsOfGroup = (
|
||||
group: gdObjectGroup,
|
||||
undeclaredVariable: AiGeneratedEventUndeclaredVariable
|
||||
) => {
|
||||
const groupVariablesContainer = gd.ObjectVariableHelper.mergeVariableContainers(
|
||||
projectScopedContainers.getObjectsContainersList(),
|
||||
group
|
||||
);
|
||||
const originalSerializedVariables = new gd.SerializerElement();
|
||||
groupVariablesContainer.serializeTo(originalSerializedVariables);
|
||||
|
||||
const variable = groupVariablesContainer.insertNew(
|
||||
undeclaredVariable.name,
|
||||
0
|
||||
);
|
||||
setupVariable(variable, undeclaredVariable.type);
|
||||
|
||||
const changeset = gd.WholeProjectRefactorer.computeChangesetForVariablesContainer(
|
||||
originalSerializedVariables,
|
||||
groupVariablesContainer
|
||||
);
|
||||
originalSerializedVariables.delete();
|
||||
|
||||
gd.WholeProjectRefactorer.applyRefactoringForGroupVariablesContainer(
|
||||
project,
|
||||
project.getObjects(),
|
||||
scene.getObjects(),
|
||||
scene.getInitialInstances(),
|
||||
groupVariablesContainer,
|
||||
group,
|
||||
changeset,
|
||||
originalSerializedVariables
|
||||
);
|
||||
};
|
||||
|
||||
undeclaredVariables.forEach(undeclaredVariable => {
|
||||
if (
|
||||
projectScopedContainers
|
||||
.getObjectsContainersList()
|
||||
.hasObjectOrGroupWithVariableNamed(objectName, undeclaredVariable.name)
|
||||
) {
|
||||
// Variable already exists, no need to add it.
|
||||
return;
|
||||
}
|
||||
|
||||
if (scene.getObjects().hasObjectNamed(objectName)) {
|
||||
const object = scene.getObjects().getObject(objectName);
|
||||
const variable = object
|
||||
.getVariables()
|
||||
.insertNew(undeclaredVariable.name, 0);
|
||||
setupVariable(variable, undeclaredVariable.type);
|
||||
} else if (
|
||||
scene
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.has(objectName)
|
||||
) {
|
||||
const group = scene
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.get(objectName);
|
||||
|
||||
addVariableForObjectsOfGroup(group, undeclaredVariable);
|
||||
} else if (project.getObjects().hasObjectNamed(objectName)) {
|
||||
const object = project.getObjects().getObject(objectName);
|
||||
const variable = object
|
||||
.getVariables()
|
||||
.insertNew(undeclaredVariable.name, 0);
|
||||
setupVariable(variable, undeclaredVariable.type);
|
||||
}
|
||||
if (
|
||||
project
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.has(objectName)
|
||||
) {
|
||||
const group = project
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.get(objectName);
|
||||
|
||||
addVariableForObjectsOfGroup(group, undeclaredVariable);
|
||||
}
|
||||
});
|
||||
};
|
376
newIDE/app/src/EditorFunctions/ApplyEventsChanges.spec.js
Normal file
376
newIDE/app/src/EditorFunctions/ApplyEventsChanges.spec.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// @flow
|
||||
import { applyEventsChanges } from './ApplyEventsChanges';
|
||||
import { type AiGeneratedEventChange } from '../Utils/GDevelopServices/Generation';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('applyEventsChanges', () => {
|
||||
let project: gdProject;
|
||||
let sceneEventsList: gdEventsList;
|
||||
let newEventsForInsertionJson = ` [{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]},{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]}]`;
|
||||
const fakeGeneratedEventId = 'fakeGeneratedEventId';
|
||||
|
||||
beforeEach(() => {
|
||||
project = new gd.ProjectHelper.createNewGDJSProject();
|
||||
sceneEventsList = new gd.EventsList();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sceneEventsList.delete();
|
||||
project.delete();
|
||||
});
|
||||
|
||||
const setupInitialSceneEvents = (eventTypes: Array<string>) => {
|
||||
sceneEventsList.clear();
|
||||
eventTypes.forEach((type, index) => {
|
||||
sceneEventsList.insertNewEvent(project, type, index);
|
||||
});
|
||||
};
|
||||
|
||||
const getEventTypes = (list: gdEventsList): Array<string> => {
|
||||
const types = [];
|
||||
for (let i = 0; i < list.getEventsCount(); i++) {
|
||||
types.push(list.getEventAt(i).getType());
|
||||
}
|
||||
return types;
|
||||
};
|
||||
|
||||
it('should delete an event at the specified path', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Comment',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
]);
|
||||
// Path "event-1" targets index 1 (the second event)
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
expect(sceneEventsList.getEventsCount()).toBe(2);
|
||||
expect(sceneEventsList.getEventAt(0).getType()).toBe(
|
||||
'BuiltinCommonInstructions::Standard'
|
||||
);
|
||||
expect(sceneEventsList.getEventAt(1).getType()).toBe(
|
||||
'BuiltinCommonInstructions::Standard'
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a sub-event', () => {
|
||||
sceneEventsList.clear();
|
||||
const parentEvent = gd.asGroupEvent(
|
||||
sceneEventsList.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Group',
|
||||
0
|
||||
)
|
||||
);
|
||||
const subEvents = parentEvent.getSubEvents();
|
||||
subEvents.insertNewEvent(project, 'BuiltinCommonInstructions::Comment', 0); // sub-event 0
|
||||
subEvents.insertNewEvent(project, 'BuiltinCommonInstructions::Standard', 1); // sub-event 1 (to delete)
|
||||
|
||||
// Path "event-0.1" targets parent at index 0, sub-event at its index 1
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-0.1',
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
|
||||
const finalParentEvent = sceneEventsList.getEventAt(0);
|
||||
expect(finalParentEvent.getSubEvents().getEventsCount()).toBe(1);
|
||||
expect(
|
||||
finalParentEvent
|
||||
.getSubEvents()
|
||||
.getEventAt(0)
|
||||
.getType()
|
||||
).toBe('BuiltinCommonInstructions::Comment');
|
||||
});
|
||||
|
||||
it('should insert events before a specified path', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
]);
|
||||
// Path "event-1" means insert at index 1 (before Repeat)
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_before_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Expected: S0, NewS, NewS, R1
|
||||
expect(sceneEventsList.getEventsCount()).toBe(4);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should append events if path targets end of list', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
]); // S0, R1. Count = 2
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_at_end',
|
||||
operationTargetEvent: null,
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Expected: S0, R1, NewS, NewS
|
||||
expect(sceneEventsList.getEventsCount()).toBe(4);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should insert events as sub-events', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Group',
|
||||
]); // S0, G1
|
||||
// Path "event-1" targets parent at index 1 ('Group')
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_as_sub_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
const parentEvent = sceneEventsList.getEventAt(1);
|
||||
expect(parentEvent.getSubEvents().getEventsCount()).toBe(2);
|
||||
expect(getEventTypes(parentEvent.getSubEvents())).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace an event', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
'BuiltinCommonInstructions::While',
|
||||
]);
|
||||
// Path "event-1" targets index 1 ('Repeat') for replacement
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_and_replace_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Expected: S0, NewS, NewS, W2 (original R1 is gone, 2 new events inserted)
|
||||
expect(sceneEventsList.getEventsCount()).toBe(4);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::While',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process deletions before insertions when paths are sorted', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
'BuiltinCommonInstructions::While',
|
||||
'BuiltinCommonInstructions::ForEach',
|
||||
]);
|
||||
// Delete W2 (idx 2, path "event-2")
|
||||
// Insert new before R1 (idx 1, path "event-1")
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-2',
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
{
|
||||
operationName: 'insert_before_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Initial: S0, R1, W2, F3
|
||||
// After del W2: S0, R1, F3
|
||||
// After insert new before R1: S0, NewS, NewS, R1, F3
|
||||
expect(sceneEventsList.getEventsCount()).toBe(5);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard', // S0
|
||||
'BuiltinCommonInstructions::Standard', // NewS
|
||||
'BuiltinCommonInstructions::Standard', // NewS
|
||||
'BuiltinCommonInstructions::Repeat', // R1
|
||||
'BuiltinCommonInstructions::ForEach', // F3
|
||||
]);
|
||||
});
|
||||
|
||||
it('should warn and skip insert if no generatedEvents provided for an insert operation', () => {
|
||||
setupInitialSceneEvents(['BuiltinCommonInstructions::Standard']);
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_before_event',
|
||||
operationTargetEvent: 'event-1', // Insert at index 1 (append)
|
||||
generatedEvents: null, // No events provided
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
|
||||
expect(sceneEventsList.getEventsCount()).toBe(1); // No events added
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Insert operation for path [1] skipped: no events to insert or events list is empty'
|
||||
)
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip invalid delete path (out of bounds) gracefully and log error', () => {
|
||||
setupInitialSceneEvents(['BuiltinCommonInstructions::Standard']);
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-5', // Path "event-5" targets index 5, out of bounds
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
expect(sceneEventsList.getEventsCount()).toBe(1); // No change
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Error applying event operation type delete for path [5]'
|
||||
),
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
174
newIDE/app/src/EditorFunctions/ApplyVariableChange.js
Normal file
174
newIDE/app/src/EditorFunctions/ApplyVariableChange.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// @flow
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const readOrInferVariableType = (
|
||||
specifiedType: string | null,
|
||||
value: string
|
||||
): string => {
|
||||
if (specifiedType) {
|
||||
const lowercaseSpecifiedType = specifiedType.toLowerCase();
|
||||
if (lowercaseSpecifiedType === 'string') {
|
||||
return 'String';
|
||||
} else if (lowercaseSpecifiedType === 'number') {
|
||||
return 'Number';
|
||||
} else if (lowercaseSpecifiedType === 'boolean') {
|
||||
return 'Boolean';
|
||||
}
|
||||
}
|
||||
|
||||
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
|
||||
return 'Boolean';
|
||||
}
|
||||
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isNaN(numberValue)) {
|
||||
return 'Number';
|
||||
}
|
||||
|
||||
return 'String';
|
||||
};
|
||||
|
||||
// Parse variable path to handle both dot notation and array indexing
|
||||
const parseVariablePath = (
|
||||
variablePath: string
|
||||
): Array<{| type: 'property' | 'index', value: string |}> => {
|
||||
const segments = [];
|
||||
let currentSegment = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < variablePath.length) {
|
||||
const char = variablePath[i];
|
||||
|
||||
if (char === '.') {
|
||||
// End of property segment
|
||||
if (currentSegment.trim()) {
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
currentSegment = '';
|
||||
}
|
||||
i++;
|
||||
} else if (char === '[') {
|
||||
// Start of array index
|
||||
if (currentSegment.trim()) {
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
currentSegment = '';
|
||||
}
|
||||
|
||||
// Find the closing bracket
|
||||
i++; // Skip opening bracket
|
||||
let indexContent = '';
|
||||
let foundClosingBracketWithProperIndex = false;
|
||||
while (i < variablePath.length && variablePath[i] !== ']') {
|
||||
indexContent += variablePath[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i < variablePath.length && variablePath[i] === ']') {
|
||||
// Valid array index
|
||||
const indexValue = indexContent.trim();
|
||||
if (indexValue && !isNaN(parseInt(indexValue, 10))) {
|
||||
segments.push({ type: 'index', value: indexValue });
|
||||
foundClosingBracketWithProperIndex = true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Content of the index is invalid ("${indexValue}") - it should be a number.`
|
||||
);
|
||||
}
|
||||
i++; // Skip closing bracket
|
||||
}
|
||||
|
||||
if (!foundClosingBracketWithProperIndex) {
|
||||
throw new Error(
|
||||
'Improperly formatted array index. Please check the variable path - it should be formatted like this: `myVar[1].prop`, `myVar`, `myVar.prop`, etc...'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
currentSegment += char;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining segment
|
||||
if (currentSegment.trim()) {
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
}
|
||||
|
||||
console.log('segments', segments);
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const applyVariableChange = ({
|
||||
variablePath,
|
||||
forcedVariableType,
|
||||
variablesContainer,
|
||||
value,
|
||||
}: {|
|
||||
variablePath: string,
|
||||
forcedVariableType: string | null,
|
||||
variablesContainer: gd.VariablesContainer,
|
||||
value: string,
|
||||
|}) => {
|
||||
const pathSegments = parseVariablePath(variablePath);
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
throw new Error('Invalid variable path');
|
||||
}
|
||||
|
||||
let addedNewVariable = false;
|
||||
const firstSegment = pathSegments[0];
|
||||
|
||||
if (firstSegment.type !== 'property') {
|
||||
throw new Error('Variable path must start with a property name');
|
||||
}
|
||||
|
||||
const firstVariableName = firstSegment.value;
|
||||
let variable = null;
|
||||
if (!variablesContainer.has(firstVariableName)) {
|
||||
variable = variablesContainer.insertNew(firstVariableName, 0);
|
||||
addedNewVariable = true;
|
||||
} else {
|
||||
variable = variablesContainer.get(firstVariableName);
|
||||
}
|
||||
|
||||
for (let i = 1; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
|
||||
if (segment.type === 'property') {
|
||||
// Navigate to structure property
|
||||
variable.castTo('Structure');
|
||||
if (!variable.hasChild(segment.value)) {
|
||||
addedNewVariable = true;
|
||||
}
|
||||
variable = variable.getChild(segment.value);
|
||||
} else if (segment.type === 'index') {
|
||||
// Navigate to array element
|
||||
const index = parseInt(segment.value, 10);
|
||||
variable.castTo('Array');
|
||||
|
||||
// Ensure array has enough elements
|
||||
while (variable.getChildrenCount() <= index) {
|
||||
variable.pushNew();
|
||||
addedNewVariable = true;
|
||||
}
|
||||
|
||||
variable = variable.getAtIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
const variableType = readOrInferVariableType(forcedVariableType, value);
|
||||
|
||||
if (variableType === 'String') {
|
||||
variable.setString(value);
|
||||
} else if (variableType === 'Number') {
|
||||
variable.setValue(parseFloat(value));
|
||||
} else if (variableType === 'Boolean') {
|
||||
variable.setBool(value.toLowerCase() === 'true');
|
||||
}
|
||||
|
||||
return {
|
||||
variable,
|
||||
variableType,
|
||||
addedNewVariable,
|
||||
};
|
||||
};
|
399
newIDE/app/src/EditorFunctions/ApplyVariableChange.spec.js
Normal file
399
newIDE/app/src/EditorFunctions/ApplyVariableChange.spec.js
Normal file
@@ -0,0 +1,399 @@
|
||||
// @flow
|
||||
import { applyVariableChange } from './ApplyVariableChange';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('applyVariableChange', () => {
|
||||
let variablesContainer: gdVariablesContainer;
|
||||
|
||||
beforeEach(() => {
|
||||
variablesContainer = new gd.VariablesContainer(
|
||||
gd.VariablesContainer.Unknown
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
variablesContainer.delete();
|
||||
});
|
||||
|
||||
describe('Root variable modification', () => {
|
||||
it('should create and set a new root string variable', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myVariable',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'hello world',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myVariable')).toBe(true);
|
||||
|
||||
const variable = variablesContainer.get('myVariable');
|
||||
expect(variable.getType()).toBe(gd.Variable.String);
|
||||
expect(variable.getString()).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should create and set a new root number variable', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myNum',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: '42.5',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myNum')).toBe(true);
|
||||
|
||||
const variable = variablesContainer.get('myNum');
|
||||
expect(variable.getType()).toBe(gd.Variable.Number);
|
||||
expect(variable.getValue()).toBe(42.5);
|
||||
});
|
||||
|
||||
it('should create and set a new root boolean variable', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myBool',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'true',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myBool')).toBe(true);
|
||||
|
||||
const variable = variablesContainer.get('myBool');
|
||||
expect(variable.getType()).toBe(gd.Variable.Boolean);
|
||||
expect(variable.getBool()).toBe(true);
|
||||
});
|
||||
|
||||
it('should modify an existing root variable', () => {
|
||||
// Setup existing variable
|
||||
const existingVar = variablesContainer.insertNew('existing', 0);
|
||||
existingVar.setString('old value');
|
||||
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'existing',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'new value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(false);
|
||||
expect(variablesContainer.get('existing').getString()).toBe('new value');
|
||||
});
|
||||
|
||||
it('should respect forced variable type', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'forcedString',
|
||||
forcedVariableType: 'String',
|
||||
variablesContainer,
|
||||
value: '123',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
const variable = variablesContainer.get('forcedString');
|
||||
expect(variable.getType()).toBe(gd.Variable.String);
|
||||
expect(variable.getString()).toBe('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structure child modification', () => {
|
||||
it('should create a new structure with child property', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myStruct.childProperty',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'child value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myStruct')).toBe(true);
|
||||
|
||||
const structVar = variablesContainer.get('myStruct');
|
||||
expect(structVar.getType()).toBe(gd.Variable.Structure);
|
||||
expect(structVar.hasChild('childProperty')).toBe(true);
|
||||
|
||||
const childVar = structVar.getChild('childProperty');
|
||||
expect(childVar.getType()).toBe(gd.Variable.String);
|
||||
expect(childVar.getString()).toBe('child value');
|
||||
});
|
||||
|
||||
it('should add property to existing structure', () => {
|
||||
// Setup existing structure
|
||||
const existingStruct = variablesContainer.insertNew('existingStruct', 0);
|
||||
existingStruct.castTo('Structure');
|
||||
existingStruct.getChild('existingProp').setString('existing');
|
||||
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'existingStruct.newProperty',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'new prop value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(existingStruct.hasChild('existingProp')).toBe(true);
|
||||
expect(existingStruct.hasChild('newProperty')).toBe(true);
|
||||
expect(existingStruct.getChild('newProperty').getString()).toBe(
|
||||
'new prop value'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nested structure properties', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'level1.level2.level3',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'deep value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const level1 = variablesContainer.get('level1');
|
||||
expect(level1.getType()).toBe(gd.Variable.Structure);
|
||||
|
||||
const level2 = level1.getChild('level2');
|
||||
expect(level2.getType()).toBe(gd.Variable.Structure);
|
||||
|
||||
const level3 = level2.getChild('level3');
|
||||
expect(level3.getString()).toBe('deep value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array item modification', () => {
|
||||
it('should create a new array and set item at index', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myArray[2]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'third item',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myArray')).toBe(true);
|
||||
|
||||
const arrayVar = variablesContainer.get('myArray');
|
||||
expect(arrayVar.getType()).toBe(gd.Variable.Array);
|
||||
expect(arrayVar.getChildrenCount()).toBe(3); // indices 0, 1, 2
|
||||
|
||||
const itemVar = arrayVar.getAtIndex(2);
|
||||
expect(itemVar.getString()).toBe('third item');
|
||||
});
|
||||
|
||||
it('should expand array when accessing higher index', () => {
|
||||
// Setup existing array with 2 items
|
||||
const existingArray = variablesContainer.insertNew('existingArray', 0);
|
||||
existingArray.castTo('Array');
|
||||
existingArray.pushNew().setString('item0');
|
||||
existingArray.pushNew().setString('item1');
|
||||
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'existingArray[4]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'item4',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(existingArray.getChildrenCount()).toBe(5); // indices 0-4
|
||||
expect(existingArray.getAtIndex(0).getString()).toBe('item0');
|
||||
expect(existingArray.getAtIndex(1).getString()).toBe('item1');
|
||||
expect(existingArray.getAtIndex(4).getString()).toBe('item4');
|
||||
});
|
||||
|
||||
it('should handle array index with whitespace', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myArray[ 1 ]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'spaced index',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
const arrayVar = variablesContainer.get('myArray');
|
||||
expect(arrayVar.getAtIndex(1).getString()).toBe('spaced index');
|
||||
});
|
||||
|
||||
it('should modify existing array item', () => {
|
||||
// Setup existing array
|
||||
const existingArray = variablesContainer.insertNew('existingArray', 0);
|
||||
existingArray.castTo('Array');
|
||||
existingArray.pushNew().setString('original');
|
||||
|
||||
applyVariableChange({
|
||||
variablePath: 'existingArray[0]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'modified',
|
||||
});
|
||||
|
||||
expect(existingArray.getAtIndex(0).getString()).toBe('modified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed structure and array access', () => {
|
||||
it('should handle array item with structure property', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myArray[1].itemProperty',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'nested value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const arrayVar = variablesContainer.get('myArray');
|
||||
expect(arrayVar.getType()).toBe(gd.Variable.Array);
|
||||
expect(arrayVar.getChildrenCount()).toBe(2); // indices 0, 1
|
||||
|
||||
const itemVar = arrayVar.getAtIndex(1);
|
||||
expect(itemVar.getType()).toBe(gd.Variable.Structure);
|
||||
expect(itemVar.hasChild('itemProperty')).toBe(true);
|
||||
expect(itemVar.getChild('itemProperty').getString()).toBe('nested value');
|
||||
});
|
||||
|
||||
it('should handle structure with array property', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myStruct.arrayProp[2]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'array in struct',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const structVar = variablesContainer.get('myStruct');
|
||||
expect(structVar.getType()).toBe(gd.Variable.Structure);
|
||||
|
||||
const arrayProp = structVar.getChild('arrayProp');
|
||||
expect(arrayProp.getType()).toBe(gd.Variable.Array);
|
||||
expect(arrayProp.getChildrenCount()).toBe(3);
|
||||
expect(arrayProp.getAtIndex(2).getString()).toBe('array in struct');
|
||||
});
|
||||
|
||||
it('should handle complex nested paths', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'players[0].inventory.items[2].name',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'Magic Sword',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const players = variablesContainer.get('players');
|
||||
const player0 = players.getAtIndex(0);
|
||||
const inventory = player0.getChild('inventory');
|
||||
const items = inventory.getChild('items');
|
||||
const item2 = items.getAtIndex(2);
|
||||
const name = item2.getChild('name');
|
||||
|
||||
expect(name.getString()).toBe('Magic Sword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error cases', () => {
|
||||
it('should throw error for empty path', () => {
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: '',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow('Invalid variable path');
|
||||
});
|
||||
|
||||
it('should throw error for path starting with array index', () => {
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: '[0]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow('Variable path must start with a property name');
|
||||
});
|
||||
|
||||
it('should handle invalid array index gracefully', () => {
|
||||
// Note: The current implementation filters out invalid indexes,
|
||||
// so this test verifies that behavior
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: 'myVar[wrong wrong].prop',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow(
|
||||
'Content of the index is invalid ("wrong wrong") - it should be a number.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed brackets', () => {
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: 'malformedVar[1.prop',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow(
|
||||
'Improperly formatted array index. Please check the variable path - it should be formatted like this: `myVar[1].prop`, `myVar`, `myVar.prop`, etc...'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type inference', () => {
|
||||
it('should infer boolean type from "true"', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testBool',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'true',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testBool');
|
||||
expect(variable.getType()).toBe(gd.Variable.Boolean);
|
||||
expect(variable.getBool()).toBe(true);
|
||||
});
|
||||
|
||||
it('should infer boolean type from "false"', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testBool',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'FALSE',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testBool');
|
||||
expect(variable.getType()).toBe(gd.Variable.Boolean);
|
||||
expect(variable.getBool()).toBe(false);
|
||||
});
|
||||
|
||||
it('should infer number type from numeric strings', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testNum',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: '3.14159',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testNum');
|
||||
expect(variable.getType()).toBe(gd.Variable.Number);
|
||||
expect(variable.getValue()).toBe(3.14159);
|
||||
});
|
||||
|
||||
it('should default to string type for mixed content', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testString',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: '123abc',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testString');
|
||||
expect(variable.getType()).toBe(gd.Variable.String);
|
||||
expect(variable.getString()).toBe('123abc');
|
||||
});
|
||||
});
|
||||
});
|
166
newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js
Normal file
166
newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// @flow
|
||||
import { type EventsGenerationResult } from '.';
|
||||
import {
|
||||
editorFunctions,
|
||||
type EditorFunction,
|
||||
type EditorCallbacks,
|
||||
type EditorFunctionCall,
|
||||
type EditorFunctionGenericOutput,
|
||||
type EventsGenerationOptions,
|
||||
type AssetSearchAndInstallOptions,
|
||||
type AssetSearchAndInstallResult,
|
||||
} from '.';
|
||||
|
||||
export type EditorFunctionCallResult =
|
||||
| {|
|
||||
status: 'working',
|
||||
call_id: string,
|
||||
|}
|
||||
| {|
|
||||
status: 'finished',
|
||||
call_id: string,
|
||||
success: boolean,
|
||||
output: any,
|
||||
|}
|
||||
| {|
|
||||
status: 'ignored',
|
||||
call_id: string,
|
||||
|};
|
||||
|
||||
export type ProcessEditorFunctionCallsOptions = {|
|
||||
project: gdProject,
|
||||
functionCalls: Array<EditorFunctionCall>,
|
||||
editorCallbacks: EditorCallbacks,
|
||||
ignore: boolean,
|
||||
generateEvents: (
|
||||
options: EventsGenerationOptions
|
||||
) => Promise<EventsGenerationResult>,
|
||||
onSceneEventsModifiedOutsideEditor: (scene: gdLayout) => void,
|
||||
ensureExtensionInstalled: (options: {|
|
||||
extensionName: string,
|
||||
|}) => Promise<void>,
|
||||
searchAndInstallAsset: (
|
||||
options: AssetSearchAndInstallOptions
|
||||
) => Promise<AssetSearchAndInstallResult>,
|
||||
|};
|
||||
|
||||
export const processEditorFunctionCalls = async ({
|
||||
functionCalls,
|
||||
project,
|
||||
editorCallbacks,
|
||||
generateEvents,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
ignore,
|
||||
ensureExtensionInstalled,
|
||||
searchAndInstallAsset,
|
||||
}: ProcessEditorFunctionCallsOptions): Promise<
|
||||
Array<EditorFunctionCallResult>
|
||||
> => {
|
||||
const results: Array<EditorFunctionCallResult> = [];
|
||||
|
||||
for (const functionCall of functionCalls) {
|
||||
const call_id = functionCall.call_id;
|
||||
if (ignore) {
|
||||
results.push({
|
||||
status: 'ignored',
|
||||
call_id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = functionCall.name;
|
||||
let args;
|
||||
try {
|
||||
try {
|
||||
args = JSON.parse(functionCall.arguments);
|
||||
} catch (error) {
|
||||
console.error('Error parsing arguments: ', error);
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success: false,
|
||||
output: {
|
||||
message: 'Invalid arguments (not a valid JSON string).',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (name === null) {
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success: false,
|
||||
output: {
|
||||
message: 'Missing or invalid function name.',
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (args === null) {
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success: false,
|
||||
output: {
|
||||
message: `Invalid arguments for function: ${name}.`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the function exists
|
||||
const editorFunction: EditorFunction | null =
|
||||
editorFunctions[name] || null;
|
||||
if (!editorFunction) {
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success: false,
|
||||
output: {
|
||||
message: `Unknown function: ${name}.`,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
const result: EditorFunctionGenericOutput = await editorFunction.launchFunction(
|
||||
{
|
||||
project,
|
||||
args,
|
||||
generateEvents,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
ensureExtensionInstalled,
|
||||
searchAndInstallAsset,
|
||||
}
|
||||
);
|
||||
const { success, ...output } = result;
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success,
|
||||
output,
|
||||
});
|
||||
|
||||
if (success && args) {
|
||||
if (typeof args.scene_name === 'string') {
|
||||
editorCallbacks.onOpenLayout(args.scene_name, {
|
||||
openEventsEditor: true,
|
||||
openSceneEditor: true,
|
||||
focusWhenOpened: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success: false,
|
||||
output: { message: error.message || 'Unknown error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
@@ -0,0 +1,279 @@
|
||||
// @flow
|
||||
import { mapFor } from '../../Utils/MapFor';
|
||||
|
||||
export type ParameterSummary = {|
|
||||
isCodeOnly?: boolean,
|
||||
name?: string,
|
||||
type: string,
|
||||
description?: string,
|
||||
isOptional?: boolean,
|
||||
extraInfo?: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A simplified summary of an instruction.
|
||||
*/
|
||||
export type InstructionSummary = {|
|
||||
type: string,
|
||||
description: string,
|
||||
parameters: Array<ParameterSummary>,
|
||||
hidden?: boolean,
|
||||
relevantForSceneEvents?: boolean,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A simplified summary of an expression.
|
||||
*/
|
||||
export type ExpressionSummary = {|
|
||||
type: string,
|
||||
description: string,
|
||||
parameters: Array<ParameterSummary>,
|
||||
hidden?: boolean,
|
||||
relevantForSceneEvents?: boolean,
|
||||
|};
|
||||
|
||||
export type ObjectSummary = {|
|
||||
name: string,
|
||||
fullName: string,
|
||||
description: string,
|
||||
actions: Array<InstructionSummary>,
|
||||
conditions: Array<InstructionSummary>,
|
||||
expressions: Array<ExpressionSummary>,
|
||||
|};
|
||||
|
||||
export type BehaviorSummary = {|
|
||||
name: string,
|
||||
fullName: string,
|
||||
description: string,
|
||||
objectType?: string,
|
||||
actions: Array<InstructionSummary>,
|
||||
conditions: Array<InstructionSummary>,
|
||||
expressions: Array<ExpressionSummary>,
|
||||
|};
|
||||
|
||||
export type ExtensionSummary = {|
|
||||
extensionName: string,
|
||||
extensionFullName: string,
|
||||
description: string,
|
||||
freeActions: Array<InstructionSummary>,
|
||||
freeConditions: Array<InstructionSummary>,
|
||||
freeExpressions: Array<ExpressionSummary>,
|
||||
objects: { [string]: ObjectSummary },
|
||||
behaviors: { [string]: BehaviorSummary },
|
||||
|};
|
||||
|
||||
const normalizeType = (parameterType: string) => {
|
||||
if (parameterType === 'expression') return 'number';
|
||||
|
||||
if (
|
||||
parameterType === 'object' ||
|
||||
parameterType === 'objectPtr' ||
|
||||
parameterType === 'objectList' ||
|
||||
parameterType === 'objectListOrEmptyIfJustDeclared' ||
|
||||
parameterType === 'objectListOrEmptyWithoutPicking'
|
||||
) {
|
||||
return 'object';
|
||||
}
|
||||
|
||||
return parameterType;
|
||||
};
|
||||
|
||||
const getParameterSummary = (
|
||||
parameterMetadata: gdParameterMetadata
|
||||
): ParameterSummary => {
|
||||
const parameterSummary: ParameterSummary = {
|
||||
type: normalizeType(parameterMetadata.getType()),
|
||||
};
|
||||
if (parameterMetadata.getDescription()) {
|
||||
parameterSummary.description = parameterMetadata.getDescription();
|
||||
}
|
||||
if (parameterMetadata.getName()) {
|
||||
parameterSummary.name = parameterMetadata.getName();
|
||||
}
|
||||
if (parameterMetadata.isCodeOnly()) {
|
||||
parameterSummary.isCodeOnly = true;
|
||||
}
|
||||
if (parameterMetadata.isOptional()) {
|
||||
parameterSummary.isOptional = true;
|
||||
}
|
||||
if (parameterMetadata.getExtraInfo()) {
|
||||
parameterSummary.extraInfo = parameterMetadata.getExtraInfo();
|
||||
}
|
||||
return parameterSummary;
|
||||
};
|
||||
|
||||
export const buildExtensionSummary = ({
|
||||
gd,
|
||||
extension,
|
||||
}: {
|
||||
gd: libGDevelop,
|
||||
extension: gdPlatformExtension,
|
||||
}): ExtensionSummary => {
|
||||
const objects: { [string]: ObjectSummary } = {};
|
||||
const behaviors: { [string]: BehaviorSummary } = {};
|
||||
|
||||
const generateInstructionsSummaries = ({
|
||||
instructionsMetadata,
|
||||
}: {
|
||||
instructionsMetadata: gdMapStringInstructionMetadata,
|
||||
}) => {
|
||||
const instructionTypes = instructionsMetadata.keys().toJSArray();
|
||||
return instructionTypes
|
||||
.map(instructionType => {
|
||||
const instructionMetadata = instructionsMetadata.get(instructionType);
|
||||
|
||||
if (instructionMetadata.isPrivate()) return null;
|
||||
|
||||
const instructionSummary: InstructionSummary = {
|
||||
type: instructionType,
|
||||
description: instructionMetadata.getDescription(),
|
||||
parameters: mapFor(
|
||||
0,
|
||||
instructionMetadata.getParameters().getParametersCount(),
|
||||
index => {
|
||||
const parameterMetadata = instructionMetadata.getParameter(index);
|
||||
return getParameterSummary(parameterMetadata);
|
||||
}
|
||||
),
|
||||
};
|
||||
if (instructionMetadata.isHidden()) {
|
||||
instructionSummary.hidden = true;
|
||||
}
|
||||
if (!instructionMetadata.isRelevantForLayoutEvents()) {
|
||||
instructionSummary.relevantForSceneEvents = false;
|
||||
}
|
||||
|
||||
return instructionSummary;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const generateExpressionSummaries = ({
|
||||
expressionsMetadata,
|
||||
}: {
|
||||
expressionsMetadata: gdMapStringExpressionMetadata,
|
||||
}) => {
|
||||
const expressionTypes = expressionsMetadata.keys().toJSArray();
|
||||
return expressionTypes
|
||||
.map(expressionType => {
|
||||
const expressionMetadata = expressionsMetadata.get(expressionType);
|
||||
|
||||
if (expressionMetadata.isPrivate()) return null;
|
||||
|
||||
const expressionSummary: ExpressionSummary = {
|
||||
type: expressionType,
|
||||
description: expressionMetadata.getDescription(),
|
||||
parameters: mapFor(
|
||||
0,
|
||||
expressionMetadata.getParameters().getParametersCount(),
|
||||
index => {
|
||||
const parameterMetadata = expressionMetadata.getParameter(index);
|
||||
return getParameterSummary(parameterMetadata);
|
||||
}
|
||||
),
|
||||
};
|
||||
if (!expressionMetadata.isShown()) {
|
||||
expressionSummary.hidden = true;
|
||||
}
|
||||
if (!expressionMetadata.isRelevantForLayoutEvents()) {
|
||||
expressionSummary.relevantForSceneEvents = false;
|
||||
}
|
||||
|
||||
return expressionSummary;
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
extension
|
||||
.getExtensionObjectsTypes()
|
||||
.toJSArray()
|
||||
.forEach(objectType => {
|
||||
const objectMetadata = extension.getObjectMetadata(objectType);
|
||||
if (
|
||||
gd.MetadataProvider.isBadObjectMetadata(objectMetadata) ||
|
||||
objectMetadata.isPrivate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
objects[objectType] = {
|
||||
name: objectMetadata.getName(),
|
||||
fullName: objectMetadata.getFullName(),
|
||||
description: objectMetadata.getDescription(),
|
||||
actions: generateInstructionsSummaries({
|
||||
instructionsMetadata: objectMetadata.getAllActions(),
|
||||
}),
|
||||
conditions: generateInstructionsSummaries({
|
||||
instructionsMetadata: objectMetadata.getAllConditions(),
|
||||
}),
|
||||
expressions: [
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: objectMetadata.getAllExpressions(),
|
||||
}),
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: objectMetadata.getAllStrExpressions(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
extension
|
||||
.getBehaviorsTypes()
|
||||
.toJSArray()
|
||||
.forEach(behaviorType => {
|
||||
const behaviorMetadata = extension.getBehaviorMetadata(behaviorType);
|
||||
if (
|
||||
gd.MetadataProvider.isBadBehaviorMetadata(behaviorMetadata) ||
|
||||
behaviorMetadata.isPrivate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const behaviorSummary: BehaviorSummary = {
|
||||
name: behaviorMetadata.getName(),
|
||||
fullName: behaviorMetadata.getFullName(),
|
||||
description: behaviorMetadata.getDescription(),
|
||||
actions: generateInstructionsSummaries({
|
||||
instructionsMetadata: behaviorMetadata.getAllActions(),
|
||||
}),
|
||||
conditions: generateInstructionsSummaries({
|
||||
instructionsMetadata: behaviorMetadata.getAllConditions(),
|
||||
}),
|
||||
expressions: [
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: behaviorMetadata.getAllExpressions(),
|
||||
}),
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: behaviorMetadata.getAllStrExpressions(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
if (behaviorMetadata.getObjectType()) {
|
||||
behaviorSummary.objectType = behaviorMetadata.getObjectType();
|
||||
}
|
||||
|
||||
behaviors[behaviorType] = behaviorSummary;
|
||||
});
|
||||
|
||||
return {
|
||||
extensionName: extension.getName(),
|
||||
extensionFullName: extension.getFullName(),
|
||||
description: extension.getDescription(),
|
||||
freeActions: generateInstructionsSummaries({
|
||||
instructionsMetadata: extension.getAllActions(),
|
||||
}),
|
||||
freeConditions: generateInstructionsSummaries({
|
||||
instructionsMetadata: extension.getAllConditions(),
|
||||
}),
|
||||
freeExpressions: [
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: extension.getAllExpressions(),
|
||||
}),
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: extension.getAllStrExpressions(),
|
||||
}),
|
||||
],
|
||||
objects,
|
||||
behaviors,
|
||||
};
|
||||
};
|
@@ -0,0 +1,386 @@
|
||||
// @flow
|
||||
import { mapFor, mapVector } from '../../Utils/MapFor';
|
||||
import { isCollectionVariable } from '../../Utils/VariablesUtils';
|
||||
import {
|
||||
buildExtensionSummary,
|
||||
type ExtensionSummary,
|
||||
} from './ExtensionSummary';
|
||||
|
||||
export type SimplifiedBehavior = {|
|
||||
behaviorName: string,
|
||||
behaviorType: string,
|
||||
|};
|
||||
|
||||
type SimplifiedVariable = {|
|
||||
variableName: string,
|
||||
type: string,
|
||||
value?: string,
|
||||
variableChildren?: Array<SimplifiedVariable>,
|
||||
|};
|
||||
|
||||
type SimplifiedObject = {|
|
||||
objectName: string,
|
||||
objectType: string,
|
||||
behaviors?: Array<SimplifiedBehavior>,
|
||||
objectVariables?: Array<SimplifiedVariable>,
|
||||
animationNames?: string,
|
||||
|};
|
||||
|
||||
type SimplifiedObjectGroup = {|
|
||||
objectGroupName: string,
|
||||
objectGroupType: string,
|
||||
objectNames: Array<string>,
|
||||
behaviors?: Array<SimplifiedBehavior>,
|
||||
variables?: Array<SimplifiedVariable>,
|
||||
|};
|
||||
|
||||
type SimplifiedScene = {|
|
||||
sceneName: string,
|
||||
objects: Array<SimplifiedObject>,
|
||||
objectGroups: Array<SimplifiedObjectGroup>,
|
||||
sceneVariables: Array<SimplifiedVariable>,
|
||||
instancesOnSceneDescription: string,
|
||||
|};
|
||||
|
||||
type SimplifiedProject = {|
|
||||
globalObjects: Array<SimplifiedObject>,
|
||||
globalObjectGroups: Array<SimplifiedObjectGroup>,
|
||||
scenes: Array<SimplifiedScene>,
|
||||
globalVariables: Array<SimplifiedVariable>,
|
||||
|};
|
||||
|
||||
type ProjectSpecificExtensionsSummary = {|
|
||||
extensionSummaries: Array<ExtensionSummary>,
|
||||
|};
|
||||
|
||||
export type SimplifiedProjectOptions = {|
|
||||
scopeToScene?: string,
|
||||
|};
|
||||
|
||||
export const makeSimplifiedProjectBuilder = (gd: libGDevelop) => {
|
||||
const getVariableType = (variable: gdVariable) => {
|
||||
const type = variable.getType();
|
||||
return type === gd.Variable.String
|
||||
? 'String'
|
||||
: type === gd.Variable.Number
|
||||
? 'Number'
|
||||
: type === gd.Variable.Boolean
|
||||
? 'Boolean'
|
||||
: type === gd.Variable.Structure
|
||||
? 'Structure'
|
||||
: type === gd.Variable.Array
|
||||
? 'Array'
|
||||
: 'unknown';
|
||||
};
|
||||
|
||||
const getVariableValueAsString = (variable: gdVariable) => {
|
||||
const type = variable.getType();
|
||||
return type === gd.Variable.Structure || type === gd.Variable.Array
|
||||
? variable.getChildrenCount() === 0
|
||||
? `No children`
|
||||
: variable.getChildrenCount() === 1
|
||||
? `1 child`
|
||||
: `${variable.getChildrenCount()} children`
|
||||
: type === gd.Variable.String
|
||||
? variable.getString()
|
||||
: type === gd.Variable.Number
|
||||
? variable.getValue().toString()
|
||||
: type === gd.Variable.Boolean
|
||||
? variable.getBool()
|
||||
? `True`
|
||||
: `False`
|
||||
: 'unknown';
|
||||
};
|
||||
|
||||
const getSimplifiedVariable = (
|
||||
name: string,
|
||||
variable: gdVariable,
|
||||
depth = 0
|
||||
): SimplifiedVariable => {
|
||||
const isCollection = isCollectionVariable(variable);
|
||||
|
||||
if (isCollection) {
|
||||
// Don't diplay children of arrays, and only display the first level of children of structures.
|
||||
if (variable.getType() === gd.Variable.Structure && depth === 0) {
|
||||
return {
|
||||
variableName: name,
|
||||
type: getVariableType(variable),
|
||||
variableChildren: variable
|
||||
.getAllChildrenNames()
|
||||
.toJSArray()
|
||||
.map(childName => {
|
||||
const childVariable = variable.getChild(childName);
|
||||
return getSimplifiedVariable(childName, childVariable, depth + 1);
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
variableName: name,
|
||||
type: getVariableType(variable),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variableName: name,
|
||||
type: getVariableType(variable),
|
||||
value: getVariableValueAsString(variable),
|
||||
};
|
||||
};
|
||||
|
||||
const getSimplifiedVariablesContainerJson = (
|
||||
container: gdVariablesContainer
|
||||
): Array<SimplifiedVariable> => {
|
||||
return mapFor(0, Math.min(container.count(), 20), (index: number) => {
|
||||
const name = container.getNameAt(index);
|
||||
const variable = container.getAt(index);
|
||||
return getSimplifiedVariable(name, variable);
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const getSimplifiedObject = (object: gdObject): SimplifiedObject => {
|
||||
const objectVariables = getSimplifiedVariablesContainerJson(
|
||||
object.getVariables()
|
||||
);
|
||||
const behaviors = object
|
||||
.getAllBehaviorNames()
|
||||
.toJSArray()
|
||||
.map(behaviorName => {
|
||||
const behavior = object.getBehavior(behaviorName);
|
||||
|
||||
return {
|
||||
behaviorName: behavior.getName(),
|
||||
behaviorType: behavior.getTypeName(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const simplifiedObject: SimplifiedObject = {
|
||||
objectName: object.getName(),
|
||||
objectType: object.getType(),
|
||||
};
|
||||
|
||||
if (behaviors.length > 0) {
|
||||
simplifiedObject.behaviors = behaviors;
|
||||
}
|
||||
if (objectVariables.length > 0) {
|
||||
simplifiedObject.objectVariables = objectVariables;
|
||||
}
|
||||
|
||||
const objectConfiguration = object.getConfiguration();
|
||||
const animationNames = mapFor(
|
||||
0,
|
||||
objectConfiguration.getAnimationsCount(),
|
||||
i => {
|
||||
return (
|
||||
objectConfiguration.getAnimationName(i) ||
|
||||
`(animation without name, animation index is: ${i})`
|
||||
);
|
||||
}
|
||||
);
|
||||
if (animationNames.length > 0) {
|
||||
simplifiedObject.animationNames = animationNames.join(', ');
|
||||
}
|
||||
|
||||
return simplifiedObject;
|
||||
};
|
||||
|
||||
const getSimplifiedObjectsJson = (
|
||||
objects: gdObjectsContainer
|
||||
): Array<SimplifiedObject> => {
|
||||
return mapFor(0, objects.getObjectsCount(), i => {
|
||||
const object = objects.getObjectAt(i);
|
||||
return getSimplifiedObject(object);
|
||||
});
|
||||
};
|
||||
|
||||
const getSimplifiedObjectGroups = (
|
||||
objectGroups: gdObjectGroupsContainer,
|
||||
objectsContainersList: gdObjectsContainersList
|
||||
): Array<SimplifiedObjectGroup> => {
|
||||
return mapFor(0, objectGroups.count(), i => {
|
||||
const objectGroup = objectGroups.getAt(i);
|
||||
|
||||
const behaviorNames = objectsContainersList
|
||||
.getBehaviorsOfObject(objectGroup.getName(), true)
|
||||
.toJSArray();
|
||||
|
||||
const variablesContainer = gd.ObjectVariableHelper.mergeVariableContainers(
|
||||
objectsContainersList,
|
||||
objectGroup
|
||||
);
|
||||
|
||||
return {
|
||||
objectGroupName: objectGroup.getName(),
|
||||
objectGroupType: objectsContainersList.getTypeOfObject(
|
||||
objectGroup.getName()
|
||||
),
|
||||
objectNames: objectGroup.getAllObjectsNames().toJSArray(),
|
||||
behaviors:
|
||||
behaviorNames.length > 0
|
||||
? behaviorNames.map(behaviorName => ({
|
||||
behaviorName,
|
||||
behaviorType: objectsContainersList.getTypeOfBehaviorInObjectOrGroup(
|
||||
objectGroup.getName(),
|
||||
behaviorName,
|
||||
true
|
||||
),
|
||||
}))
|
||||
: undefined,
|
||||
variables:
|
||||
variablesContainer.count() > 0
|
||||
? getSimplifiedVariablesContainerJson(variablesContainer)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getInstancesDescription = (scene: gdLayout): string => {
|
||||
let isEmpty = true;
|
||||
const instancesCountPerLayer: { [string]: { [string]: number } } = {};
|
||||
|
||||
const instancesListerFunctor = new gd.InitialInstanceJSFunctor();
|
||||
// $FlowFixMe - invoke is not writable
|
||||
instancesListerFunctor.invoke = instancePtr => {
|
||||
// $FlowFixMe - wrapPointer is not exposed
|
||||
const instance: gdInitialInstance = gd.wrapPointer(
|
||||
instancePtr,
|
||||
gd.InitialInstance
|
||||
);
|
||||
const name = instance.getObjectName();
|
||||
if (!name) return;
|
||||
|
||||
const layer = instance.getLayer();
|
||||
|
||||
const layerInstancesCount = (instancesCountPerLayer[layer] =
|
||||
instancesCountPerLayer[layer] || {});
|
||||
layerInstancesCount[name] = (layerInstancesCount[name] || 0) + 1;
|
||||
isEmpty = false;
|
||||
};
|
||||
// $FlowFixMe - JSFunctor is incompatible with Functor
|
||||
scene.getInitialInstances().iterateOverInstances(instancesListerFunctor);
|
||||
instancesListerFunctor.delete();
|
||||
|
||||
if (isEmpty) {
|
||||
return 'There are no instances of objects placed on the scene - the scene is empty.';
|
||||
}
|
||||
|
||||
const layersContainer = scene.getLayers();
|
||||
|
||||
return [
|
||||
`On the scene, there are:`,
|
||||
...mapFor(0, layersContainer.getLayersCount(), i => {
|
||||
const layer = layersContainer.getLayerAt(i);
|
||||
const layerName = layer.getName();
|
||||
const layerInstancesCount = instancesCountPerLayer[layerName];
|
||||
|
||||
return [
|
||||
layerName ? `- on layer "${layer.getName()}":` : `- on base layer:`,
|
||||
!layerInstancesCount || Object.keys(layerInstancesCount).length === 0
|
||||
? ` - Nothing (no instances)`
|
||||
: Object.keys(layerInstancesCount)
|
||||
.map(name => ` - ${layerInstancesCount[name]} ${name}`)
|
||||
.join('\n'),
|
||||
].join('\n');
|
||||
}),
|
||||
'',
|
||||
`Inspect instances on the scene to get more details if needed.`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const getSimplifiedScene = (
|
||||
project: gdProject,
|
||||
scene: gdLayout
|
||||
): SimplifiedScene => {
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
|
||||
project,
|
||||
scene
|
||||
);
|
||||
|
||||
return {
|
||||
sceneName: scene.getName(),
|
||||
objects: getSimplifiedObjectsJson(scene.getObjects()),
|
||||
objectGroups: getSimplifiedObjectGroups(
|
||||
scene.getObjects().getObjectGroups(),
|
||||
projectScopedContainers.getObjectsContainersList()
|
||||
),
|
||||
sceneVariables: getSimplifiedVariablesContainerJson(scene.getVariables()),
|
||||
instancesOnSceneDescription: getInstancesDescription(scene),
|
||||
};
|
||||
};
|
||||
|
||||
const getSimplifiedProject = (
|
||||
project: gdProject,
|
||||
options: SimplifiedProjectOptions
|
||||
): SimplifiedProject => {
|
||||
const globalObjects = getSimplifiedObjectsJson(project.getObjects());
|
||||
const scenes = mapFor(0, project.getLayoutsCount(), i => {
|
||||
const scene = project.getLayoutAt(i);
|
||||
if (options.scopeToScene && scene.getName() !== options.scopeToScene)
|
||||
return null;
|
||||
|
||||
return getSimplifiedScene(project, scene);
|
||||
}).filter(Boolean);
|
||||
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProject(
|
||||
project
|
||||
);
|
||||
|
||||
// Filter extensions to only include extensions from the project.
|
||||
|
||||
const simplifiedProject: SimplifiedProject = {
|
||||
globalObjects,
|
||||
globalObjectGroups: getSimplifiedObjectGroups(
|
||||
project.getObjects().getObjectGroups(),
|
||||
projectScopedContainers.getObjectsContainersList()
|
||||
),
|
||||
scenes,
|
||||
globalVariables: getSimplifiedVariablesContainerJson(
|
||||
project.getVariables()
|
||||
),
|
||||
};
|
||||
|
||||
return simplifiedProject;
|
||||
};
|
||||
|
||||
const getProjectSpecificExtensionsSummary = (
|
||||
project: gdProject
|
||||
): ProjectSpecificExtensionsSummary => {
|
||||
const startTime = Date.now();
|
||||
const platform = project.getCurrentPlatform();
|
||||
const allExtensions = platform.getAllPlatformExtensions();
|
||||
|
||||
const projectExtensionNames = new Set(
|
||||
mapFor(0, project.getEventsFunctionsExtensionsCount(), i => {
|
||||
const extension = project.getEventsFunctionsExtensionAt(i);
|
||||
return extension.getName();
|
||||
})
|
||||
);
|
||||
|
||||
const projectSpecificExtensions: Array<gdPlatformExtension> = mapVector(
|
||||
allExtensions,
|
||||
extension => {
|
||||
if (projectExtensionNames.has(extension.getName())) {
|
||||
return extension;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
).filter(Boolean);
|
||||
|
||||
const extensionsSummary: ProjectSpecificExtensionsSummary = {
|
||||
extensionSummaries: projectSpecificExtensions.map(extension =>
|
||||
buildExtensionSummary({ gd, extension })
|
||||
),
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.info(
|
||||
`Project specific extensions summary generated in ${duration.toFixed(
|
||||
0
|
||||
)}ms`
|
||||
);
|
||||
|
||||
return extensionsSummary;
|
||||
};
|
||||
|
||||
return { getSimplifiedProject, getProjectSpecificExtensionsSummary };
|
||||
};
|
@@ -0,0 +1,906 @@
|
||||
// @flow
|
||||
import { makeSimplifiedProjectBuilder } from './SimplifiedProject';
|
||||
import { makeTestProject } from '../../fixtures/TestProject';
|
||||
import { makeTestExtensions } from '../../fixtures/TestExtensions';
|
||||
const initializeGDevelopJs = require('libGD.js-for-tests-only');
|
||||
|
||||
describe('SimplifiedProject', () => {
|
||||
it('should create a simplified project JSON with global objects and scenes', async () => {
|
||||
const gd = await initializeGDevelopJs();
|
||||
const { project } = makeTestProject(gd);
|
||||
const simplifiedJson = makeSimplifiedProjectBuilder(
|
||||
gd
|
||||
).getSimplifiedProject(project, {});
|
||||
|
||||
expect(simplifiedJson).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"globalObjectGroups": Array [],
|
||||
"globalObjects": Array [
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "GlobalTiledSpriteObject",
|
||||
"objectType": "TiledSpriteObject::TiledSprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Text",
|
||||
"behaviorType": "TextContainerCapability::TextContainerBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "GlobalTextObject",
|
||||
"objectType": "TextObject::Text",
|
||||
},
|
||||
],
|
||||
"globalVariables": Array [],
|
||||
"scenes": Array [
|
||||
Object {
|
||||
"instancesOnSceneDescription": "On the scene, there are:
|
||||
- on layer \\"GUI\\":
|
||||
- Nothing (no instances)
|
||||
- on layer \\"OtherLayer\\":
|
||||
- Nothing (no instances)
|
||||
- on base layer:
|
||||
- 1 CubeObject
|
||||
- 1 TextInputObject
|
||||
- 1 MySpriteObject
|
||||
|
||||
Inspect instances on the scene to get more details if needed.",
|
||||
"objectGroups": Array [
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectGroupName": "GroupOfSprites",
|
||||
"objectGroupType": "Sprite",
|
||||
"objectNames": Array [
|
||||
"MySpriteObject",
|
||||
],
|
||||
"variables": Array [
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "A multiline
|
||||
str value",
|
||||
"variableName": "ObjectVariable",
|
||||
},
|
||||
Object {
|
||||
"type": "Structure",
|
||||
"variableChildren": Array [
|
||||
Object {
|
||||
"type": "Number",
|
||||
"value": "564",
|
||||
"variableName": "ObjectChild1",
|
||||
},
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "Guttentag",
|
||||
"variableName": "ObjectChild2",
|
||||
},
|
||||
Object {
|
||||
"type": "Boolean",
|
||||
"value": "True",
|
||||
"variableName": "ObjectChild3",
|
||||
},
|
||||
Object {
|
||||
"type": "Array",
|
||||
"variableName": "ObjectChild4",
|
||||
},
|
||||
],
|
||||
"variableName": "OtherObjectVariable",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectGroupName": "GroupOfObjects",
|
||||
"objectGroupType": "",
|
||||
"objectNames": Array [
|
||||
"MySpriteObject",
|
||||
"MyTextObject",
|
||||
],
|
||||
"variables": undefined,
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Draggable",
|
||||
"behaviorType": "DraggableBehavior::Draggable",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "PlatformerObject",
|
||||
"behaviorType": "PlatformBehavior::PlatformerObjectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectGroupName": "GroupOfSpriteObjectsWithBehaviors",
|
||||
"objectGroupType": "Sprite",
|
||||
"objectNames": Array [
|
||||
"MySpriteObjectWithBehaviors",
|
||||
],
|
||||
"variables": undefined,
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectGroupName": "MyGroupWithObjectsHavingLongName",
|
||||
"objectGroupType": "Sprite",
|
||||
"objectNames": Array [
|
||||
"MySpriteObject",
|
||||
"MySpriteObject_With_A_Veeeerrryyyyyyyyy_Looooooooooooong_Name",
|
||||
"MySpriteObjectWithoutBehaviors",
|
||||
],
|
||||
"variables": undefined,
|
||||
},
|
||||
],
|
||||
"objects": Array [
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MySpriteObjectWithEffects",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MySpriteObjectWithoutEffect",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MySpriteObjectWithoutBehaviors",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Draggable",
|
||||
"behaviorType": "DraggableBehavior::Draggable",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "PlatformerObject",
|
||||
"behaviorType": "PlatformBehavior::PlatformerObjectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MySpriteObjectWithBehaviors",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MyEmptySpriteObject",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"animationNames": "My animation, My other animation, (animation without name, animation index is: 2)",
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MySpriteObject",
|
||||
"objectType": "Sprite",
|
||||
"objectVariables": Array [
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "A multiline
|
||||
str value",
|
||||
"variableName": "ObjectVariable",
|
||||
},
|
||||
Object {
|
||||
"type": "Structure",
|
||||
"variableChildren": Array [
|
||||
Object {
|
||||
"type": "Number",
|
||||
"value": "564",
|
||||
"variableName": "ObjectChild1",
|
||||
},
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "Guttentag",
|
||||
"variableName": "ObjectChild2",
|
||||
},
|
||||
Object {
|
||||
"type": "Boolean",
|
||||
"value": "True",
|
||||
"variableName": "ObjectChild3",
|
||||
},
|
||||
Object {
|
||||
"type": "Array",
|
||||
"variableName": "ObjectChild4",
|
||||
},
|
||||
],
|
||||
"variableName": "OtherObjectVariable",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MyPanelSpriteObject",
|
||||
"objectType": "PanelSpriteObject::PanelSprite",
|
||||
},
|
||||
Object {
|
||||
"objectName": "TextInputObject",
|
||||
"objectType": "FakeTextInput::TextInput",
|
||||
},
|
||||
Object {
|
||||
"objectName": "CubeObject",
|
||||
"objectType": "FakeScene3D::Cube3DObject",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MyTiledSpriteObject",
|
||||
"objectType": "TiledSpriteObject::TiledSprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MyParticleEmitter",
|
||||
"objectType": "ParticleSystem::ParticleEmitter",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Text",
|
||||
"behaviorType": "TextContainerCapability::TextContainerBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MyTextObject",
|
||||
"objectType": "TextObject::Text",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MyShapePainterObject",
|
||||
"objectType": "PrimitiveDrawing::Drawer",
|
||||
},
|
||||
Object {
|
||||
"objectName": "MyButton",
|
||||
"objectType": "Button::PanelSpriteButton",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "MySpriteObject_With_A_Veeeerrryyyyyyyyy_Looooooooooooong_Name",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"objectName": "MyFakeObjectWithUnsupportedCapability",
|
||||
"objectType": "FakeObjectWithUnsupportedCapability::FakeObjectWithUnsupportedCapability",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "VirtualControls",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "VirtualControls1",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "VirtualControls2",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "VirtualControls3",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "VirtualControls4",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
Object {
|
||||
"behaviors": Array [
|
||||
Object {
|
||||
"behaviorName": "Animation",
|
||||
"behaviorType": "AnimatableCapability::AnimatableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Effect",
|
||||
"behaviorType": "EffectCapability::EffectBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Flippable",
|
||||
"behaviorType": "FlippableCapability::FlippableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Opacity",
|
||||
"behaviorType": "OpacityCapability::OpacityBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Resizable",
|
||||
"behaviorType": "ResizableCapability::ResizableBehavior",
|
||||
},
|
||||
Object {
|
||||
"behaviorName": "Scale",
|
||||
"behaviorType": "ScalableCapability::ScalableBehavior",
|
||||
},
|
||||
],
|
||||
"objectName": "VirtualControls5",
|
||||
"objectType": "Sprite",
|
||||
},
|
||||
],
|
||||
"sceneName": "TestLayout",
|
||||
"sceneVariables": Array [
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "A multiline
|
||||
str value",
|
||||
"variableName": "Variable1",
|
||||
},
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "123456",
|
||||
"variableName": "Variable2",
|
||||
},
|
||||
Object {
|
||||
"type": "Structure",
|
||||
"variableChildren": Array [
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "Child1 str value",
|
||||
"variableName": "Child1",
|
||||
},
|
||||
Object {
|
||||
"type": "String",
|
||||
"value": "7891011",
|
||||
"variableName": "Child2",
|
||||
},
|
||||
Object {
|
||||
"type": "Structure",
|
||||
"variableName": "FoldedChild",
|
||||
},
|
||||
],
|
||||
"variableName": "Variable3",
|
||||
},
|
||||
Object {
|
||||
"type": "Array",
|
||||
"variableName": "FoldedArray",
|
||||
},
|
||||
Object {
|
||||
"type": "Array",
|
||||
"variableName": "OtherArray",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"instancesOnSceneDescription": "There are no instances of objects placed on the scene - the scene is empty.",
|
||||
"objectGroups": Array [],
|
||||
"objects": Array [],
|
||||
"sceneName": "EmptyLayout",
|
||||
"sceneVariables": Array [],
|
||||
},
|
||||
Object {
|
||||
"instancesOnSceneDescription": "There are no instances of objects placed on the scene - the scene is empty.",
|
||||
"objectGroups": Array [],
|
||||
"objects": Array [],
|
||||
"sceneName": "Layout with a very looooooooong naaaaame to test in the project manager",
|
||||
"sceneVariables": Array [],
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should include summaries of project specific extensions', async () => {
|
||||
const gd = await initializeGDevelopJs();
|
||||
makeTestExtensions(gd);
|
||||
|
||||
const project = gd.ProjectHelper.createNewGDJSProject();
|
||||
// Mimic the test extension "FakeBehavior" was created from a project extension:
|
||||
project.insertNewEventsFunctionsExtension('FakeBehavior', 0);
|
||||
|
||||
const projectSpecificExtensionsSummary = makeSimplifiedProjectBuilder(
|
||||
gd
|
||||
).getProjectSpecificExtensionsSummary(project);
|
||||
|
||||
expect(projectSpecificExtensionsSummary).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"extensionSummaries": Array [
|
||||
Object {
|
||||
"behaviors": Object {
|
||||
"FakeBehavior::FakeBehavior": Object {
|
||||
"actions": Array [],
|
||||
"conditions": Array [],
|
||||
"description": "A fake behavior with two properties.",
|
||||
"expressions": Array [
|
||||
Object {
|
||||
"description": "Some expression returning a number",
|
||||
"parameters": Array [
|
||||
Object {
|
||||
"description": "First parameter (number)",
|
||||
"type": "number",
|
||||
},
|
||||
],
|
||||
"type": "SomethingReturningNumberWith1NumberParam",
|
||||
},
|
||||
Object {
|
||||
"description": "Some expression returning a string",
|
||||
"parameters": Array [
|
||||
Object {
|
||||
"description": "First parameter (number)",
|
||||
"type": "number",
|
||||
},
|
||||
],
|
||||
"type": "SomethingReturningStringWith1NumberParam",
|
||||
},
|
||||
],
|
||||
"fullName": "Fake behavior with two properties",
|
||||
"name": "FakeBehavior::FakeBehavior",
|
||||
},
|
||||
},
|
||||
"description": "A fake extension with a fake behavior containing 2 properties.",
|
||||
"extensionFullName": "Fake extension with a fake behavior",
|
||||
"extensionName": "FakeBehavior",
|
||||
"freeActions": Array [],
|
||||
"freeConditions": Array [],
|
||||
"freeExpressions": Array [],
|
||||
"objects": Object {},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
project.delete();
|
||||
});
|
||||
});
|
2524
newIDE/app/src/EditorFunctions/index.js
Normal file
2524
newIDE/app/src/EditorFunctions/index.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,8 @@ import Text from '../UI/Text';
|
||||
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu';
|
||||
import Add from '../UI/CustomSvgIcons/Add';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -28,7 +28,8 @@ import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import DropIndicator from '../UI/SortableVirtualizedItemList/DropIndicator';
|
||||
import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget';
|
||||
import useForceUpdate from '../Utils/UseForceUpdate';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -27,7 +27,8 @@ import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import DropIndicator from '../UI/SortableVirtualizedItemList/DropIndicator';
|
||||
import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget';
|
||||
import useForceUpdate from '../Utils/UseForceUpdate';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -29,7 +29,8 @@ import { DragHandleIcon } from '../../UI/DragHandle';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import DropIndicator from '../../UI/SortableVirtualizedItemList/DropIndicator';
|
||||
import { makeDragSourceAndDropTarget } from '../../UI/DragAndDrop/DragSourceAndDropTarget';
|
||||
import Clipboard, { SafeExtractor } from '../../Utils/Clipboard';
|
||||
import Clipboard from '../../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -89,7 +89,7 @@ type Props = {|
|
||||
eventsFunctionsExtension: gdEventsFunctionsExtension,
|
||||
name: string
|
||||
) => void,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
type State = {|
|
||||
|
@@ -5,7 +5,8 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -5,7 +5,8 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -5,7 +5,8 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
type SelectionState,
|
||||
getSelectedEvents,
|
||||
|
@@ -0,0 +1,254 @@
|
||||
// @flow
|
||||
import { makeTestProject } from '../../../fixtures/TestProject';
|
||||
import { unserializeFromJSObject } from '../../../Utils/Serializer';
|
||||
import { renderNonTranslatedEventsAsText } from '.';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('EventsTree/TextRenderer', () => {
|
||||
it('renders events as text', () => {
|
||||
const { project } = makeTestProject(gd);
|
||||
try {
|
||||
const serializedEvents = [
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [
|
||||
{
|
||||
type: {
|
||||
value: 'BuiltinCommonInstructions::And',
|
||||
},
|
||||
parameters: [],
|
||||
subInstructions: [
|
||||
{
|
||||
type: {
|
||||
inverted: true,
|
||||
value: 'PlatformBehavior::IsFalling',
|
||||
},
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: {
|
||||
inverted: true,
|
||||
value: 'PlatformBehavior::IsFalling',
|
||||
},
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: {
|
||||
value: 'BuiltinCommonInstructions::And',
|
||||
},
|
||||
parameters: [],
|
||||
subInstructions: [],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Cache' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
{
|
||||
type: { value: 'ThisActionDoesNotExist' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::While',
|
||||
whileConditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Repeat',
|
||||
repeatExpression: '3 + 4',
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Group',
|
||||
name: 'My super group',
|
||||
events: [
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const eventsList = new gd.EventsList();
|
||||
unserializeFromJSObject(
|
||||
eventsList,
|
||||
serializedEvents,
|
||||
'unserializeFrom',
|
||||
project
|
||||
);
|
||||
|
||||
expect(
|
||||
renderNonTranslatedEventsAsText({
|
||||
eventsList,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<event-0>
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
Sub-events:
|
||||
<event-0.0>
|
||||
Conditions:
|
||||
- If all of these conditions are true:
|
||||
- (inverted) GroupOfSpriteObjectsWithBehaviors is falling
|
||||
- (inverted) GroupOfSpriteObjectsWithBehaviors is falling
|
||||
- If all of these conditions are true:
|
||||
(no conditions)
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Hide GroupOfObjects
|
||||
- Unknown or unsupported instruction
|
||||
</event-0.0>
|
||||
<event-0.1>
|
||||
While these conditions are true:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Then do:
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
</event-0.1>
|
||||
<event-0.2>
|
||||
Repeat \`3 + 4\` times these:
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
</event-0.2>
|
||||
<event-0.3>
|
||||
Group called \\"My super group\\":
|
||||
Sub-events:
|
||||
<event-0.3.0>
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
</event-0.3.0>
|
||||
<event-0.3.1>
|
||||
Conditions:
|
||||
(no conditions)
|
||||
Actions:
|
||||
(no actions)
|
||||
</event-0.3.1>
|
||||
</event-0.3>
|
||||
</event-0>"
|
||||
`);
|
||||
} finally {
|
||||
project.delete();
|
||||
}
|
||||
});
|
||||
});
|
275
newIDE/app/src/EventsSheet/EventsTree/TextRenderer/index.js
Normal file
275
newIDE/app/src/EventsSheet/EventsTree/TextRenderer/index.js
Normal file
@@ -0,0 +1,275 @@
|
||||
// @flow
|
||||
|
||||
import { mapFor } from '../../../Utils/MapFor';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const renderInstructionsAsText = ({
|
||||
instructionsList,
|
||||
padding,
|
||||
areConditions,
|
||||
}: {|
|
||||
instructionsList: gdInstructionsList,
|
||||
padding: string,
|
||||
areConditions: boolean,
|
||||
|}) => {
|
||||
const renderInstruction = (instruction: gdInstruction) => {
|
||||
const invertedText = instruction.isInverted() ? '(inverted) ' : '';
|
||||
const metadata = areConditions
|
||||
? gd.MetadataProvider.getConditionMetadata(
|
||||
gd.JsPlatform.get(),
|
||||
instruction.getType()
|
||||
)
|
||||
: gd.MetadataProvider.getActionMetadata(
|
||||
gd.JsPlatform.get(),
|
||||
instruction.getType()
|
||||
);
|
||||
|
||||
const formattedTexts = gd.InstructionSentenceFormatter.get().getAsFormattedText(
|
||||
instruction,
|
||||
metadata
|
||||
);
|
||||
|
||||
const sentence = mapFor(0, formattedTexts.size(), i => {
|
||||
const value = formattedTexts.getString(i);
|
||||
return value;
|
||||
}).join('');
|
||||
|
||||
return {
|
||||
text: `${padding}- ${[invertedText, sentence].filter(Boolean).join('')}`,
|
||||
canHaveSubInstructions: metadata.canHaveSubInstructions(),
|
||||
};
|
||||
};
|
||||
|
||||
if (instructionsList.size() === 0) {
|
||||
return areConditions
|
||||
? `${padding}(no conditions)`
|
||||
: `${padding}(no actions)`;
|
||||
}
|
||||
|
||||
return mapFor(0, instructionsList.size(), i => {
|
||||
const instruction = instructionsList.get(i);
|
||||
const { text, canHaveSubInstructions } = renderInstruction(instruction);
|
||||
|
||||
const subInstructionsText = canHaveSubInstructions
|
||||
? renderInstructionsAsText({
|
||||
instructionsList: instruction.getSubInstructions(),
|
||||
padding: padding + ' ',
|
||||
areConditions,
|
||||
})
|
||||
: '';
|
||||
|
||||
return [text, subInstructionsText].filter(Boolean).join('\n');
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const eventsTextRenderers: {
|
||||
[string]: ({|
|
||||
event: gdBaseEvent,
|
||||
padding: string,
|
||||
|}) => string,
|
||||
} = {
|
||||
'BuiltinCommonInstructions::Standard': ({ event, padding }) => {
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: standardEvent.getConditions(),
|
||||
padding: padding,
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: standardEvent.getActions(),
|
||||
padding: padding,
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Comment': ({ event, padding }) => {
|
||||
return `${padding}(comment - content is not displayed)`;
|
||||
},
|
||||
'BuiltinCommonInstructions::While': ({ event, padding }) => {
|
||||
const whileEvent = gd.asWhileEvent(event);
|
||||
const whileConditions = renderInstructionsAsText({
|
||||
instructionsList: whileEvent.getWhileConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: whileEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: whileEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}While these conditions are true:
|
||||
${whileConditions}
|
||||
${padding}Then do:
|
||||
${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Repeat': ({ event, padding }) => {
|
||||
const repeatEvent = gd.asRepeatEvent(event);
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: repeatEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: repeatEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}Repeat \`${repeatEvent
|
||||
.getRepeatExpression()
|
||||
.getPlainString()}\` times these:
|
||||
${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::ForEach': ({ event, padding }) => {
|
||||
const forEachEvent = gd.asForEachEvent(event);
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: forEachEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: forEachEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}Repeat these separately for each instance of ${forEachEvent.getObjectToPick()}:
|
||||
${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::ForEachChildVariable': ({ event, padding }) => {
|
||||
const forEachChildVariableEvent = gd.asForEachChildVariableEvent(event);
|
||||
const valueIteratorName = forEachChildVariableEvent.getValueIteratorVariableName();
|
||||
const keyIteratorName = forEachChildVariableEvent.getKeyIteratorVariableName();
|
||||
const iterableName = forEachChildVariableEvent.getIterableVariableName();
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: forEachChildVariableEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: forEachChildVariableEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}For each child in \`${iterableName ||
|
||||
'(no variable chosen yet)'}\`, store the child in variable \`${valueIteratorName ||
|
||||
'(ignored)'}\`, the child name in \`${keyIteratorName ||
|
||||
'(ignored)'}\` and do:
|
||||
${padding}Conditions:
|
||||
${padding}${conditions}
|
||||
${padding}Actions:
|
||||
${padding}${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Group': ({ event, padding }) => {
|
||||
const groupEvent = gd.asGroupEvent(event);
|
||||
return `${padding}Group called "${groupEvent.getName()}":`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Link': ({ event, padding }) => {
|
||||
return `${padding}(link to events in events sheet called "${event.getTarget()}")`;
|
||||
},
|
||||
};
|
||||
|
||||
const renderEventAsText = ({
|
||||
event,
|
||||
padding,
|
||||
eventPath,
|
||||
}: {|
|
||||
event: gdBaseEvent,
|
||||
padding: string,
|
||||
eventPath: string,
|
||||
|}) => {
|
||||
const isDisabled = event.isDisabled();
|
||||
if (isDisabled) return `${padding}(This event is disabled - ignored)`;
|
||||
|
||||
const textRenderer = eventsTextRenderers[event.getType()];
|
||||
const eventText = textRenderer
|
||||
? textRenderer({ event, padding })
|
||||
: `${padding}(This event is unknown/unsupported - ignored)`;
|
||||
|
||||
let subEvents = '';
|
||||
if (event.canHaveSubEvents()) {
|
||||
subEvents = renderEventsAsText({
|
||||
eventsList: event.getSubEvents(),
|
||||
parentPath: eventPath,
|
||||
padding: padding + ' ',
|
||||
});
|
||||
}
|
||||
|
||||
return eventText + (subEvents ? `\n${padding}Sub-events:\n${subEvents}` : '');
|
||||
};
|
||||
|
||||
export const renderEventsAsText = ({
|
||||
eventsList,
|
||||
parentPath,
|
||||
padding,
|
||||
}: {|
|
||||
eventsList: gdEventsList,
|
||||
parentPath: string,
|
||||
padding: string,
|
||||
|}): string => {
|
||||
return mapFor(0, eventsList.getEventsCount(), i => {
|
||||
const event = eventsList.getEventAt(i);
|
||||
|
||||
const eventPath = (parentPath ? parentPath + '.' : '') + i;
|
||||
const eventAndSubEventsText = renderEventAsText({
|
||||
event,
|
||||
eventPath,
|
||||
padding: padding + ' ',
|
||||
});
|
||||
|
||||
return `${padding}<event-${eventPath}>
|
||||
${eventAndSubEventsText}
|
||||
${padding}</event-${eventPath}>`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
export const renderNonTranslatedEventsAsText = ({
|
||||
eventsList,
|
||||
}: {
|
||||
eventsList: gdEventsList,
|
||||
}) => {
|
||||
// Temporarily override the getTranslation function to return the original
|
||||
// string, so that events are always rendered in English.
|
||||
// $FlowFixMe
|
||||
const previousGetTranslation = gd.getTranslation;
|
||||
// $FlowFixMe
|
||||
gd.getTranslation = (str: string) => str;
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
text = renderEventsAsText({
|
||||
eventsList,
|
||||
parentPath: '',
|
||||
padding: '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error while rendering events as text:', error);
|
||||
text = 'Error while rendering events as text.';
|
||||
} finally {
|
||||
// $FlowFixMe
|
||||
gd.getTranslation = previousGetTranslation;
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
@@ -71,7 +71,7 @@ type Props = {|
|
||||
anchorEl?: any, // Unused
|
||||
canPasteInstructions: boolean, // Unused
|
||||
onPasteInstructions: () => void, // Unused
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
const getInitialStepName = (isNewInstruction: boolean): StepName => {
|
||||
|
@@ -56,7 +56,7 @@ type Props = {|
|
||||
i18n: I18nType,
|
||||
canPasteInstructions: boolean, // Unused
|
||||
onPasteInstructions: () => void, // Unused
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
/**
|
||||
|
@@ -154,7 +154,7 @@ type Props = {|
|
||||
unsavedChanges?: ?UnsavedChanges,
|
||||
isActive: boolean,
|
||||
hotReloadPreviewButtonProps: HotReloadPreviewButtonProps,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
|};
|
||||
|
||||
type ComponentProps = {|
|
||||
@@ -357,6 +357,27 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
if (this._eventsTree) this._eventsTree.forceEventsUpdate();
|
||||
};
|
||||
|
||||
onEventsModifiedOutsideEditor = () => {
|
||||
console.info(
|
||||
'Events were modified outside of the editor - dropping selection and storing this in history.'
|
||||
);
|
||||
this.setState(
|
||||
{
|
||||
// It's important to immediately clear the selection as it could contain references
|
||||
// to events that have been deleted/invalidated in memory.
|
||||
selection: clearSelection(),
|
||||
inlineEditing: false,
|
||||
inlineEditingAnchorEl: null,
|
||||
},
|
||||
() => {
|
||||
this._saveChangesToHistory('EDIT', {
|
||||
positionsBeforeAction: [],
|
||||
positionAfterAction: [],
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
updateToolbar() {
|
||||
if (!this.props.setToolbar) return;
|
||||
|
||||
@@ -1449,16 +1470,20 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
eventsHistory: newEventsHistory,
|
||||
},
|
||||
() => {
|
||||
// Whether it is an ADD, EDIT or DELETE, scroll to the place where it was done.
|
||||
eventsTree.scrollToRow(positions.positionsBeforeAction[0]);
|
||||
// Hack: because of the virtualization and the undo/redo, we lose the heights of events
|
||||
// (at least some, because they are different objects in memory).
|
||||
// While they are recomputed when rendered, scroll again to be sure we don't end
|
||||
// up at the very beginning (if everything was recomputed from 0) or at
|
||||
// an offset too large.
|
||||
setTimeout(() => {
|
||||
eventsTree.scrollToRow(positions.positionsBeforeAction[0]);
|
||||
}, 70);
|
||||
const row = positions.positionsBeforeAction[0];
|
||||
|
||||
if (row !== undefined) {
|
||||
// Whether it is an ADD, EDIT or DELETE, scroll to the place where it was done.
|
||||
eventsTree.scrollToRow(row);
|
||||
// Hack: because of the virtualization and the undo/redo, we lose the heights of events
|
||||
// (at least some, because they are different objects in memory).
|
||||
// While they are recomputed when rendered, scroll again to be sure we don't end
|
||||
// up at the very beginning (if everything was recomputed from 0) or at
|
||||
// an offset too large.
|
||||
setTimeout(() => {
|
||||
eventsTree.scrollToRow(row);
|
||||
}, 70);
|
||||
}
|
||||
this.updateToolbar();
|
||||
}
|
||||
);
|
||||
@@ -1504,16 +1529,20 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
eventsHistory: newEventsHistory,
|
||||
},
|
||||
() => {
|
||||
// Whether it was an ADD, EDIT or DELETE, scroll to the place where it will happen.
|
||||
eventsTree.scrollToRow(positions.positionsBeforeAction[0]);
|
||||
// Hack: because of the virtualization and the undo/redo, we lose the heights of events
|
||||
// (at least some, because they are different objects in memory).
|
||||
// While they are recomputed when rendered, scroll again to be sure we don't end
|
||||
// up at the very beginning (if everything was recomputed from 0) or at
|
||||
// an offset too large.
|
||||
setTimeout(() => {
|
||||
eventsTree.scrollToRow(positions.positionsBeforeAction[0]);
|
||||
}, 70);
|
||||
const row = positions.positionsBeforeAction[0];
|
||||
|
||||
if (row !== undefined) {
|
||||
// Whether it was an ADD, EDIT or DELETE, scroll to the place where it will happen.
|
||||
eventsTree.scrollToRow(row);
|
||||
// Hack: because of the virtualization and the undo/redo, we lose the heights of events
|
||||
// (at least some, because they are different objects in memory).
|
||||
// While they are recomputed when rendered, scroll again to be sure we don't end
|
||||
// up at the very beginning (if everything was recomputed from 0) or at
|
||||
// an offset too large.
|
||||
setTimeout(() => {
|
||||
eventsTree.scrollToRow(row);
|
||||
}, 70);
|
||||
}
|
||||
this.updateToolbar();
|
||||
}
|
||||
);
|
||||
@@ -2201,6 +2230,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
|
||||
export type EventsSheetInterface = {|
|
||||
updateToolbar: () => void,
|
||||
onResourceExternallyChanged: ({| identifier: string |}) => void,
|
||||
onEventsModifiedOutsideEditor: () => void,
|
||||
|};
|
||||
|
||||
// EventsSheet is a wrapper so that the component can use multiple
|
||||
@@ -2209,6 +2239,7 @@ const EventsSheet = (props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
updateToolbar,
|
||||
onResourceExternallyChanged,
|
||||
onEventsModifiedOutsideEditor,
|
||||
}));
|
||||
|
||||
const component = React.useRef<?EventsSheetComponentWithoutHandle>(null);
|
||||
@@ -2219,6 +2250,9 @@ const EventsSheet = (props, ref) => {
|
||||
if (component.current)
|
||||
component.current.onResourceExternallyChanged(resourceInfo);
|
||||
};
|
||||
const onEventsModifiedOutsideEditor = () => {
|
||||
if (component.current) component.current.onEventsModifiedOutsideEditor();
|
||||
};
|
||||
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
|
@@ -371,6 +371,7 @@ export default class LocalPreviewLauncher extends React.Component<
|
||||
}
|
||||
id="Preview over wifi"
|
||||
title={<Trans>Preview over wifi</Trans>}
|
||||
placementId="preview-wifi"
|
||||
mode="try"
|
||||
isNotShownDuringInAppTutorial
|
||||
/>
|
||||
@@ -382,6 +383,7 @@ export default class LocalPreviewLauncher extends React.Component<
|
||||
title={
|
||||
<Trans>Live preview (apply changes to the running preview)</Trans>
|
||||
}
|
||||
placementId="hot-reloading"
|
||||
mode="try"
|
||||
isNotShownDuringInAppTutorial
|
||||
/>
|
||||
|
@@ -326,6 +326,7 @@ const InviteHome = ({ cloudProjectId }: Props) => {
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason="Add collaborators on project"
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_startup"
|
||||
placementId="invite-collaborators"
|
||||
>
|
||||
<Text>
|
||||
<Trans>
|
||||
|
@@ -350,6 +350,7 @@ function LeaderboardAppearanceDialog({
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason="Leaderboard customization"
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_silver"
|
||||
placementId="leaderboards-customization"
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
@@ -406,6 +407,7 @@ function LeaderboardAppearanceDialog({
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason="Leaderboard customization"
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_startup"
|
||||
placementId="leaderboards-customization"
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
|
@@ -256,6 +256,7 @@ function LeaderboardOptionsDialog({
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason="Leaderboard customization"
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_startup"
|
||||
placementId="leaderboards-customization"
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
|
@@ -47,6 +47,7 @@ const MaxLeaderboardCountAlertMessage = () => {
|
||||
<Column expand>
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason="Leaderboard count per game limit reached"
|
||||
placementId="leaderboards"
|
||||
label={
|
||||
!hasValidSubscription ? (
|
||||
<Trans>Upgrade to GDevelop Premium</Trans>
|
||||
|
@@ -98,6 +98,7 @@ const ServicesWidget = ({
|
||||
analyticsMetadata: {
|
||||
reason: 'Leaderboard count per game limit reached',
|
||||
recommendedPlanId: 'gdevelop_silver',
|
||||
placementId: 'leaderboards',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@@ -1,536 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ColumnStackLayout,
|
||||
ResponsiveLineStackLayout,
|
||||
} from '../../../UI/Layout';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { type AiRequest } from '../../../Utils/GDevelopServices/Generation';
|
||||
import RaisedButton from '../../../UI/RaisedButton';
|
||||
import { CompactTextAreaField } from '../../../UI/CompactTextAreaField';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import LeftLoader from '../../../UI/LeftLoader';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { ChatMarkdownText } from './ChatMarkdownText';
|
||||
import ScrollView, { type ScrollViewInterface } from '../../../UI/ScrollView';
|
||||
import AlertMessage from '../../../UI/AlertMessage';
|
||||
import classes from './AiRequestChat.module.css';
|
||||
import RobotIcon from '../../../ProjectCreation/RobotIcon';
|
||||
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import GetSubscriptionCard from '../../../Profile/Subscription/GetSubscriptionCard';
|
||||
import { type Quota } from '../../../Utils/GDevelopServices/Usage';
|
||||
import IconButton from '../../../UI/IconButton';
|
||||
import Like from '../../../UI/CustomSvgIcons/Like';
|
||||
import Dislike from '../../../UI/CustomSvgIcons/Dislike';
|
||||
import Copy from '../../../UI/CustomSvgIcons/Copy';
|
||||
import GDevelopThemeContext from '../../../UI/Theme/GDevelopThemeContext';
|
||||
import { type MessageDescriptor } from '../../../Utils/i18n/MessageDescriptor.flow';
|
||||
import Link from '../../../UI/Link';
|
||||
import { getHelpLink } from '../../../Utils/HelpLink';
|
||||
import Window from '../../../Utils/Window';
|
||||
import { DislikeFeedbackDialog } from './DislikeFeedbackDialog';
|
||||
|
||||
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
|
||||
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
|
||||
|
||||
type Props = {
|
||||
aiRequest: AiRequest | null,
|
||||
|
||||
isLaunchingAiRequest: boolean,
|
||||
onSendUserRequest: string => Promise<void>,
|
||||
onSendFeedback: (
|
||||
aiRequestId: string,
|
||||
messageIndex: number,
|
||||
feedback: 'like' | 'dislike',
|
||||
reason?: string
|
||||
) => Promise<void>,
|
||||
hasOpenedProject: boolean,
|
||||
|
||||
// Error that occurred while sending the last request.
|
||||
lastSendError: ?Error,
|
||||
|
||||
// Quota available for using the feature.
|
||||
quota: Quota | null,
|
||||
increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none',
|
||||
aiRequestPriceInCredits: number | null,
|
||||
availableCredits: number,
|
||||
};
|
||||
|
||||
export type AiRequestChatInterface = {|
|
||||
resetUserInput: () => void,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
chatBubble: {
|
||||
paddingTop: 5,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
};
|
||||
|
||||
type ChatBubbleProps = {|
|
||||
children: React.Node,
|
||||
feedbackButtons?: React.Node,
|
||||
role: 'assistant' | 'user',
|
||||
|};
|
||||
|
||||
const ChatBubble = ({ children, feedbackButtons, role }: ChatBubbleProps) => {
|
||||
return (
|
||||
<div className={classes.chatBubbleContainer}>
|
||||
<Paper
|
||||
background={role === 'user' ? 'light' : 'medium'}
|
||||
style={styles.chatBubble}
|
||||
>
|
||||
<div className={classes.chatBubbleContent}>{children}</div>
|
||||
{feedbackButtons}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
|
||||
(
|
||||
{
|
||||
aiRequest,
|
||||
isLaunchingAiRequest,
|
||||
onSendUserRequest,
|
||||
onSendFeedback,
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
lastSendError,
|
||||
aiRequestPriceInCredits,
|
||||
availableCredits,
|
||||
hasOpenedProject,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
const [userRequestText, setUserRequestText] = React.useState('');
|
||||
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
|
||||
const [messageFeedbacks, setMessageFeedbacks] = React.useState({});
|
||||
const theme = React.useContext(GDevelopThemeContext);
|
||||
const [
|
||||
dislikeFeedbackDialogOpenedFor,
|
||||
setDislikeFeedbackDialogOpenedFor,
|
||||
] = React.useState(null);
|
||||
|
||||
const [newChatPlaceholder] = React.useState(() => {
|
||||
const newChatPlaceholders: Array<MessageDescriptor> = [
|
||||
t`How to add a leaderboard?`,
|
||||
t`How to display the health of my player?`,
|
||||
t`How to add an explosion when an enemy is destroyed?`,
|
||||
t`How to create a main menu for my game?`,
|
||||
...(hasOpenedProject
|
||||
? [
|
||||
t`What would you add to my game?`,
|
||||
t`How to make my game more fun?`,
|
||||
t`What is a good GDevelop feature I could use in my game?`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return newChatPlaceholders[
|
||||
Math.floor(Math.random() * newChatPlaceholders.length)
|
||||
];
|
||||
});
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
resetUserInput: () => {
|
||||
setUserRequestText('');
|
||||
if (scrollViewRef.current) {
|
||||
scrollViewRef.current.scrollToBottom({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
|
||||
const quotaOrCreditsExplanation = !quota ? null /* User is probably not even logged in. */ : !quota.limitReached ? (
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
You still have {quota.max - quota.current} free answers from the AI.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You still have {quota.max - quota.current} free answers with your
|
||||
membership (refreshed every month).
|
||||
</Trans>
|
||||
)
|
||||
) : aiRequestPriceInCredits ? (
|
||||
availableCredits ? (
|
||||
<Trans>
|
||||
Use an AI request for <b>{aiRequestPriceInCredits} credits</b> – you
|
||||
have {availableCredits} credits.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Use an AI request for <b>{aiRequestPriceInCredits} credits.</b>
|
||||
</Trans>
|
||||
)
|
||||
) : null;
|
||||
|
||||
const subscriptionBanner =
|
||||
quota && quota.limitReached && increaseQuotaOffering !== 'none' ? (
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason={
|
||||
increaseQuotaOffering === 'subscribe'
|
||||
? 'AI requests (subscribe)'
|
||||
: 'AI requests (upgrade)'
|
||||
}
|
||||
label={
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>Get GDevelop premium</Trans>
|
||||
) : (
|
||||
<Trans>Upgrade</Trans>
|
||||
)
|
||||
}
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_gold"
|
||||
canHide
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
<Text noMargin>
|
||||
{increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
Unlock AI requests included with a GDevelop premium plan.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Get even more AI requests included with a higher premium
|
||||
plan.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Line>
|
||||
</GetSubscriptionCard>
|
||||
) : null;
|
||||
|
||||
const errorOrQuotaOrCreditsExplanation = (
|
||||
<Text size="body2" color={lastSendError ? 'error' : 'secondary'}>
|
||||
{lastSendError ? (
|
||||
<Trans>
|
||||
An error happened when sending your request, please try again.
|
||||
</Trans>
|
||||
) : (
|
||||
quotaOrCreditsExplanation || '\u00a0'
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (!aiRequest) {
|
||||
return (
|
||||
<div className={classes.newChatContainer}>
|
||||
<ColumnStackLayout justifyContent="center" expand>
|
||||
<Line noMargin justifyContent="center">
|
||||
<RobotIcon rotating size={40} />
|
||||
</Line>
|
||||
<Column noMargin alignItems="center">
|
||||
<Text size="bold-title">
|
||||
<Trans>What do you want to make?</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout justifyContent="center" noMargin>
|
||||
<Column noMargin alignItems="stretch" justifyContent="stretch">
|
||||
<CompactTextAreaField
|
||||
maxLength={6000}
|
||||
value={userRequestText}
|
||||
disabled={isLaunchingAiRequest}
|
||||
onChange={userRequestText =>
|
||||
setUserRequestText(userRequestText)
|
||||
}
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
placeholder={newChatPlaceholder}
|
||||
rows={5}
|
||||
/>
|
||||
</Column>
|
||||
<Line noMargin>
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
expand
|
||||
>
|
||||
{!isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<LeftLoader reserveSpace isLoading={isLaunchingAiRequest}>
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
label={<Trans>Send</Trans>}
|
||||
style={{ flexShrink: 0 }}
|
||||
disabled={isLaunchingAiRequest || !userRequestText}
|
||||
onClick={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
/>
|
||||
</LeftLoader>
|
||||
</Line>
|
||||
{isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
</ResponsiveLineStackLayout>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
{subscriptionBanner}
|
||||
</ColumnStackLayout>
|
||||
<Column justifyContent="center">
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
The AI is experimental and still being improved.{' '}
|
||||
<Link
|
||||
href={getHelpLink('/interface/ask-ai')}
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(getHelpLink('/interface/ask-ai'))
|
||||
}
|
||||
>
|
||||
It has access to your game objects but not events.
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>Answers may have mistakes: always verify them.</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userMessagesCount = aiRequest.output.filter(
|
||||
message => message.role === 'user'
|
||||
).length;
|
||||
|
||||
return (
|
||||
<ColumnStackLayout
|
||||
expand
|
||||
alignItems="stretch"
|
||||
justifyContent="stretch"
|
||||
useFullHeight
|
||||
>
|
||||
<ScrollView ref={scrollViewRef}>
|
||||
{aiRequest.output.flatMap((message, messageIndex) => {
|
||||
if (message.role === 'user') {
|
||||
return [
|
||||
<Line key={messageIndex} justifyContent="flex-end">
|
||||
<ChatBubble role="user">
|
||||
<ChatMarkdownText
|
||||
source={message.content
|
||||
.map(messageContent => messageContent.text)
|
||||
.join('\n')}
|
||||
/>
|
||||
</ChatBubble>
|
||||
</Line>,
|
||||
];
|
||||
}
|
||||
if (message.role === 'assistant') {
|
||||
return [
|
||||
...message.content
|
||||
.map((messageContent, messageContentIndex) => {
|
||||
const key = `messageIndex${messageIndex}-${messageContentIndex}`;
|
||||
if (messageContent.type === 'output_text') {
|
||||
const feedbackKey = `${messageIndex}-${messageContentIndex}`;
|
||||
const currentFeedback = messageFeedbacks[feedbackKey];
|
||||
|
||||
return (
|
||||
<Line key={key} justifyContent="flex-start">
|
||||
<ChatBubble
|
||||
role="assistant"
|
||||
feedbackButtons={
|
||||
<div className={classes.feedbackButtonsContainer}>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`Copy`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
messageContent.text
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`This was helpful`}
|
||||
onClick={() => {
|
||||
setMessageFeedbacks({
|
||||
...messageFeedbacks,
|
||||
[feedbackKey]: 'like',
|
||||
});
|
||||
onSendFeedback(
|
||||
aiRequest.id,
|
||||
messageIndex,
|
||||
'like'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Like
|
||||
fontSize="small"
|
||||
htmlColor={
|
||||
currentFeedback === 'like'
|
||||
? theme.message.valid
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`This needs improvement`}
|
||||
onClick={() => {
|
||||
setMessageFeedbacks({
|
||||
...messageFeedbacks,
|
||||
[feedbackKey]: 'dislike',
|
||||
});
|
||||
setDislikeFeedbackDialogOpenedFor({
|
||||
aiRequestId: aiRequest.id,
|
||||
messageIndex,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Dislike
|
||||
fontSize="small"
|
||||
htmlColor={
|
||||
currentFeedback === 'dislike'
|
||||
? theme.message.warning
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatMarkdownText source={messageContent.text} />
|
||||
</ChatBubble>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
if (messageContent.type === 'reasoning') {
|
||||
return (
|
||||
<Line key={key} justifyContent="flex-start">
|
||||
<ChatBubble role="assistant">
|
||||
<ChatMarkdownText
|
||||
source={messageContent.summary.text}
|
||||
/>
|
||||
</ChatBubble>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
})}
|
||||
|
||||
{aiRequest.status === 'error' ? (
|
||||
<Line justifyContent="flex-start">
|
||||
<AlertMessage kind="error">
|
||||
<Trans>
|
||||
The AI encountered an error while handling your request - this
|
||||
was request was not counted in your AI usage. Try again later.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
) : aiRequest.status === 'working' ? (
|
||||
<Line justifyContent="flex-start">
|
||||
<div className={classes.thinkingText}>
|
||||
<LeftLoader isLoading>
|
||||
<Text noMargin displayInlineAsSpan>
|
||||
<Trans>Thinking about your request...</Trans>
|
||||
</Text>
|
||||
</LeftLoader>
|
||||
</div>
|
||||
</Line>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
{userMessagesCount >= TOO_MANY_USER_MESSAGES_WARNING_COUNT ? (
|
||||
<AlertMessage
|
||||
kind={
|
||||
userMessagesCount >= TOO_MANY_USER_MESSAGES_ERROR_COUNT
|
||||
? 'error'
|
||||
: 'warning'
|
||||
}
|
||||
>
|
||||
<Trans>
|
||||
The chat is becoming long - consider creating a new chat to ask
|
||||
other questions. The AI will better analyze your game and request
|
||||
in a new chat.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : (
|
||||
subscriptionBanner
|
||||
)}
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout
|
||||
justifyContent="stretch"
|
||||
alignItems="stretch"
|
||||
noMargin
|
||||
>
|
||||
<CompactTextAreaField
|
||||
maxLength={6000}
|
||||
value={userRequestText}
|
||||
disabled={isLaunchingAiRequest}
|
||||
onChange={userRequestText => setUserRequestText(userRequestText)}
|
||||
placeholder={t`Ask a follow up question`}
|
||||
rows={2}
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
/>
|
||||
<Column noMargin alignItems="flex-end">
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
expand
|
||||
>
|
||||
{!isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<LeftLoader reserveSpace isLoading={isLaunchingAiRequest}>
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
disabled={aiRequest.status === 'working'}
|
||||
label={<Trans>Send</Trans>}
|
||||
onClick={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
/>
|
||||
</LeftLoader>
|
||||
</Line>
|
||||
{isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
</ResponsiveLineStackLayout>
|
||||
</Column>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
{dislikeFeedbackDialogOpenedFor && (
|
||||
<DislikeFeedbackDialog
|
||||
open
|
||||
onClose={() => setDislikeFeedbackDialogOpenedFor(null)}
|
||||
onSendFeedback={(reason: string) => {
|
||||
onSendFeedback(
|
||||
dislikeFeedbackDialogOpenedFor.aiRequestId,
|
||||
dislikeFeedbackDialogOpenedFor.messageIndex,
|
||||
'dislike',
|
||||
reason
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
);
|
||||
}
|
||||
);
|
@@ -1,110 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { ColumnStackLayout } from '../../../UI/Layout';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
|
||||
import Radio from '@material-ui/core/Radio';
|
||||
import RadioGroup from '@material-ui/core/RadioGroup';
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
|
||||
type DislikeFeedbackDialogProps = {|
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onSendFeedback: (reason: string) => void,
|
||||
|};
|
||||
|
||||
export const DislikeFeedbackDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
onSendFeedback,
|
||||
}: DislikeFeedbackDialogProps) => {
|
||||
const [selectedReason, setSelectedReason] = React.useState<?string>(null);
|
||||
|
||||
const handleChange = (event: { target: { value: string } }) => {
|
||||
setSelectedReason(event.target.value);
|
||||
};
|
||||
|
||||
const handleSendFeedback = () => {
|
||||
if (selectedReason) {
|
||||
onSendFeedback(selectedReason);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={<Trans>What could be improved?</Trans>}
|
||||
actions={[
|
||||
<FlatButton
|
||||
key="cancel"
|
||||
label={<Trans>Cancel</Trans>}
|
||||
onClick={onClose}
|
||||
/>,
|
||||
<DialogPrimaryButton
|
||||
key="send"
|
||||
primary
|
||||
label={<Trans>Send feedback</Trans>}
|
||||
onClick={handleSendFeedback}
|
||||
disabled={!selectedReason}
|
||||
/>,
|
||||
]}
|
||||
open={open}
|
||||
onRequestClose={onClose}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<ColumnStackLayout noMargin>
|
||||
<Text>
|
||||
<Trans>
|
||||
Help us improve by telling us what was wrong with the answer:
|
||||
</Trans>
|
||||
</Text>
|
||||
<RadioGroup value={selectedReason || ''} onChange={handleChange}>
|
||||
<FormControlLabel
|
||||
value="not-in-my-language"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is not in my language</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="non-existing-things"
|
||||
control={<Radio color="secondary" />}
|
||||
label={
|
||||
<Trans>Some things in the answer don't exist in GDevelop</Trans>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="not-as-good-as-it-could-be"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is not as good as it could be</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="very-wrong-answer"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is entirely wrong</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="out-of-scope"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is out of scope for GDevelop</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="too-short"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is too short</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="too-long"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>The answer is too long</Trans>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="other"
|
||||
control={<Radio color="secondary" />}
|
||||
label={<Trans>Other reason</Trans>}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</ColumnStackLayout>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@@ -1,317 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type RenderEditorContainerPropsWithRef } from '../BaseEditor';
|
||||
import { type ObjectWithContext } from '../../../ObjectsList/EnumerateObjects';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { AiRequestChat, type AiRequestChatInterface } from './AiRequestChat';
|
||||
import {
|
||||
addUserMessageToAiRequest,
|
||||
createAiRequest,
|
||||
getAiRequest,
|
||||
sendAiRequestFeedback,
|
||||
type AiRequest,
|
||||
} from '../../../Utils/GDevelopServices/Generation';
|
||||
import { delay } from '../../../Utils/Delay';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import { AskAiHistory } from './AskAiHistory';
|
||||
import { getSimplifiedProjectJson } from '../../../Utils/SimplifiedProjectJson';
|
||||
import {
|
||||
canUpgradeSubscription,
|
||||
hasValidSubscriptionPlan,
|
||||
} from '../../../Utils/GDevelopServices/Usage';
|
||||
import { retryIfFailed } from '../../../Utils/RetryIfFailed';
|
||||
import { CreditsPackageStoreContext } from '../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
|
||||
type Props = {|
|
||||
isActive: boolean,
|
||||
project: ?gdProject,
|
||||
setToolbar: (?React.Node) => void,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
chatContainer: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: 'min(100%, 800px)',
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
marginBottom: 10,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export type AskAiEditorInterface = {|
|
||||
getProject: () => void,
|
||||
updateToolbar: () => void,
|
||||
forceUpdateEditor: () => void,
|
||||
onEventsBasedObjectChildrenEdited: () => void,
|
||||
onSceneObjectEdited: (
|
||||
scene: gdLayout,
|
||||
objectWithContext: ObjectWithContext
|
||||
) => void,
|
||||
onSceneObjectsDeleted: (scene: gdLayout) => void,
|
||||
|};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const AskAi = React.memo<Props>(
|
||||
React.forwardRef<Props, AskAiEditorInterface>(
|
||||
({ isActive, setToolbar, project }: Props, ref) => {
|
||||
const [
|
||||
selectedAiRequest,
|
||||
setSelectedAiRequest,
|
||||
] = React.useState<AiRequest | null>(null);
|
||||
const [lastError, setLastError] = React.useState<Error | null>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
|
||||
|
||||
const onStartNewChat = React.useCallback(() => {
|
||||
setSelectedAiRequest(null);
|
||||
}, []);
|
||||
|
||||
const onOpenHistory = React.useCallback(() => {
|
||||
setIsHistoryOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
setIsHistoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const canStartNewChat = !!selectedAiRequest;
|
||||
const updateToolbar = React.useCallback(
|
||||
() => {
|
||||
if (setToolbar) {
|
||||
setToolbar(
|
||||
<Toolbar
|
||||
onStartNewChat={onStartNewChat}
|
||||
canStartNewChat={canStartNewChat}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[setToolbar, onStartNewChat, canStartNewChat, onOpenHistory]
|
||||
);
|
||||
|
||||
React.useEffect(updateToolbar, [updateToolbar]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getProject: noop,
|
||||
updateToolbar,
|
||||
forceUpdateEditor: noop,
|
||||
onEventsBasedObjectChildrenEdited: noop,
|
||||
onSceneObjectEdited: noop,
|
||||
onSceneObjectsDeleted: noop,
|
||||
}));
|
||||
|
||||
const aiRequestChatRef = React.useRef<AiRequestChatInterface | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isLaunchingAiRequest, setIsLaunchingAiRequest] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
const { openCreditsPackageDialog } = React.useContext(
|
||||
CreditsPackageStoreContext
|
||||
);
|
||||
|
||||
const {
|
||||
profile,
|
||||
getAuthorizationHeader,
|
||||
onOpenCreateAccountDialog,
|
||||
limits,
|
||||
onRefreshLimits,
|
||||
subscription,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const availableCredits = limits ? limits.credits.userBalance.amount : 0;
|
||||
const quota =
|
||||
(limits && limits.quotas && limits.quotas['ai-request']) || null;
|
||||
const aiRequestPriceInCredits =
|
||||
(limits &&
|
||||
limits.credits &&
|
||||
limits.credits.prices['ai-request'] &&
|
||||
limits.credits.prices['ai-request'].priceInCredits) ||
|
||||
null;
|
||||
|
||||
// Refresh limits when navigating ot this tab, as we want to be sure
|
||||
// we display the proper quota and credits information for the user.
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (isActive) {
|
||||
onRefreshLimits();
|
||||
}
|
||||
},
|
||||
[isActive, onRefreshLimits]
|
||||
);
|
||||
|
||||
const selectedAiRequestId = selectedAiRequest
|
||||
? selectedAiRequest.id
|
||||
: null;
|
||||
const sendUserRequest = React.useCallback(
|
||||
async (userRequestText: string) => {
|
||||
if (!profile) {
|
||||
onOpenCreateAccountDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
let payWithCredits = false;
|
||||
if (quota && quota.limitReached && aiRequestPriceInCredits) {
|
||||
payWithCredits = true;
|
||||
if (availableCredits < aiRequestPriceInCredits) {
|
||||
openCreditsPackageDialog({
|
||||
missingCredits: aiRequestPriceInCredits - availableCredits,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const simplifiedProjectJson = project
|
||||
? JSON.stringify(getSimplifiedProjectJson(project))
|
||||
: null;
|
||||
|
||||
try {
|
||||
setIsLaunchingAiRequest(true);
|
||||
setLastError(null);
|
||||
|
||||
let aiRequest;
|
||||
try {
|
||||
// Either create a new ai request or continue the existing one with a new message.
|
||||
aiRequest = selectedAiRequestId
|
||||
? await addUserMessageToAiRequest(getAuthorizationHeader, {
|
||||
aiRequestId: selectedAiRequestId,
|
||||
userId: profile.id,
|
||||
userRequest: userRequestText,
|
||||
simplifiedProjectJson,
|
||||
payWithCredits,
|
||||
})
|
||||
: await createAiRequest(getAuthorizationHeader, {
|
||||
userRequest: userRequestText,
|
||||
userId: profile.id,
|
||||
simplifiedProjectJson,
|
||||
payWithCredits,
|
||||
});
|
||||
} finally {
|
||||
setIsLaunchingAiRequest(false);
|
||||
}
|
||||
setIsLaunchingAiRequest(false);
|
||||
setSelectedAiRequest(aiRequest);
|
||||
if (aiRequestChatRef.current) {
|
||||
aiRequestChatRef.current.resetUserInput();
|
||||
}
|
||||
|
||||
while (aiRequest.status === 'working') {
|
||||
await delay(1000);
|
||||
aiRequest = await getAiRequest(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId: aiRequest.id,
|
||||
});
|
||||
setSelectedAiRequest(aiRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
setLastError(error);
|
||||
}
|
||||
|
||||
// Refresh the user limits, to ensure quota and credits information
|
||||
// is up-to-date after an AI request.
|
||||
await delay(500);
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, onRefreshLimits);
|
||||
} catch (error) {
|
||||
// Ignore limits refresh error.
|
||||
}
|
||||
},
|
||||
[
|
||||
profile,
|
||||
quota,
|
||||
aiRequestPriceInCredits,
|
||||
project,
|
||||
onOpenCreateAccountDialog,
|
||||
availableCredits,
|
||||
openCreditsPackageDialog,
|
||||
selectedAiRequestId,
|
||||
getAuthorizationHeader,
|
||||
onRefreshLimits,
|
||||
]
|
||||
);
|
||||
|
||||
const onSendFeedback = React.useCallback(
|
||||
async (aiRequestId, messageIndex, feedback, reason) => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, () =>
|
||||
sendAiRequestFeedback(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId,
|
||||
messageIndex,
|
||||
feedback,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error sending feedback: ', error);
|
||||
}
|
||||
},
|
||||
[getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper square background="dark" style={styles.paper}>
|
||||
<div style={styles.chatContainer}>
|
||||
<AiRequestChat
|
||||
ref={aiRequestChatRef}
|
||||
aiRequest={selectedAiRequest}
|
||||
onSendUserRequest={sendUserRequest}
|
||||
isLaunchingAiRequest={isLaunchingAiRequest}
|
||||
lastSendError={lastError}
|
||||
quota={quota}
|
||||
increaseQuotaOffering={
|
||||
!hasValidSubscriptionPlan(subscription)
|
||||
? 'subscribe'
|
||||
: canUpgradeSubscription(subscription)
|
||||
? 'upgrade'
|
||||
: 'none'
|
||||
}
|
||||
aiRequestPriceInCredits={aiRequestPriceInCredits}
|
||||
availableCredits={availableCredits}
|
||||
onSendFeedback={onSendFeedback}
|
||||
hasOpenedProject={!!project}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
<AskAiHistory
|
||||
open={isHistoryOpen}
|
||||
onClose={onCloseHistory}
|
||||
onSelectAiRequest={setSelectedAiRequest}
|
||||
selectedAiRequestId={selectedAiRequestId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
),
|
||||
// Prevent any update to the editor if the editor is not active,
|
||||
// and so not visible to the user.
|
||||
(prevProps, nextProps) => prevProps.isActive || nextProps.isActive
|
||||
);
|
||||
|
||||
export const renderAskAiContainer = (
|
||||
props: RenderEditorContainerPropsWithRef
|
||||
) => (
|
||||
<AskAi
|
||||
ref={props.ref}
|
||||
project={props.project}
|
||||
setToolbar={props.setToolbar}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
);
|
@@ -55,7 +55,18 @@ export type RenderEditorContainerProps = {|
|
||||
|
||||
// Opening other editors:
|
||||
onOpenExternalEvents: string => void,
|
||||
onOpenLayout: string => void,
|
||||
onOpenLayout: (
|
||||
sceneName: string,
|
||||
options?: {|
|
||||
openEventsEditor: boolean,
|
||||
openSceneEditor: boolean,
|
||||
focusWhenOpened:
|
||||
| 'scene-or-events-otherwise'
|
||||
| 'scene'
|
||||
| 'events'
|
||||
| 'none',
|
||||
|}
|
||||
) => void,
|
||||
onOpenEvents: (sceneName: string) => void,
|
||||
openInstructionOrExpression: (
|
||||
extension: gdPlatformExtension,
|
||||
@@ -131,6 +142,7 @@ export type RenderEditorContainerProps = {|
|
||||
i18n: I18nType,
|
||||
isQuickCustomization?: boolean
|
||||
) => Promise<void>,
|
||||
onCreateEmptyProject: (newProjectSetup: NewProjectSetup) => Promise<void>,
|
||||
onOpenTemplateFromTutorial: (tutorialId: string) => Promise<void>,
|
||||
onOpenTemplateFromCourseChapter: (
|
||||
CourseChapter,
|
||||
@@ -153,6 +165,9 @@ export type RenderEditorContainerProps = {|
|
||||
) => void,
|
||||
onSceneObjectsDeleted: (scene: gdLayout) => void,
|
||||
|
||||
// Events editing
|
||||
onSceneEventsModifiedOutsideEditor: (scene: gdLayout) => void,
|
||||
|
||||
onExtractAsExternalLayout: (name: string) => void,
|
||||
onExtractAsEventBasedObject: (
|
||||
extensionName: string,
|
||||
@@ -167,7 +182,7 @@ export type RenderEditorContainerProps = {|
|
||||
eventsBasedObjectName: string,
|
||||
variantName: string
|
||||
) => void,
|
||||
onExtensionInstalled: (extensionName: string) => void,
|
||||
onExtensionInstalled: (extensionNames: Array<string>) => void,
|
||||
onDeleteEventsBasedObjectVariant: (
|
||||
eventsFunctionsExtension: gdEventsFunctionsExtension,
|
||||
eventBasedObject: gdEventsBasedObject,
|
||||
|
@@ -106,6 +106,10 @@ export class CustomObjectEditorContainer extends React.Component<RenderEditorCon
|
||||
// No thing to be done.
|
||||
}
|
||||
|
||||
onSceneEventsModifiedOutsideEditor(scene: gdLayout) {
|
||||
// No thing to be done.
|
||||
}
|
||||
|
||||
saveUiSettings = () => {
|
||||
// const layout = this.getCustomObject();
|
||||
// const editor = this.editor;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user