Compare commits

...

42 Commits

Author SHA1 Message Date
Cursor Agent
366bfca4cd Implement cloud project restoration for AI agent requests
Co-authored-by: florian <florian@gdevelop.io>
2025-06-30 16:10:41 +00:00
Cursor Agent
d58cd9c340 Add project restoration feature for AI agent mode
Co-authored-by: florian <florian@gdevelop.io>
2025-06-30 15:53:16 +00:00
D8H
08b05c13b6 Fix a crash when using the "Fixed rotation" action on a 3D character (#7680) 2025-06-30 15:24:14 +02:00
Aurélien Vivet
eb55c85f4e Fix "Wheel offset Z" and "Front wheel drive" properties of the 3D car behavior (#7678) 2025-06-30 15:23:41 +02:00
Florian Rival
8a243440db Improve some mathematical tools descriptions 2025-06-29 14:25:36 +02:00
Florian Rival
b3e4e6b89c Fix missing MassCenterZ expression for the 3D physics behavior 2025-06-28 16:35:37 +02:00
Florian Rival
a1a25f6df4 Bump newIDE version 2025-06-26 13:46:07 +02:00
Aurélien Vivet
6114a6cec1 Update the Create action with information about object picking (#7673) 2025-06-26 12:32:20 +02:00
github-actions[bot]
5058964937 Update translations [skip ci] (#7672)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-06-26 12:32:02 +02:00
Florian Rival
4488675540 Fix tutorials not always showing the element to scroll to for behaviors or extensions 2025-06-26 11:25:38 +02:00
D8H
6a2d2c9e67 Hide the behavior update dialog during in-app tutorial (#7674) 2025-06-26 11:02:55 +02:00
Florian Rival
b43c42d763 Fix broken tutorials 2025-06-25 18:50:58 +02:00
github-actions[bot]
69112183d4 Update translations [skip ci] (#7665)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-06-24 18:50:23 +02:00
Florian Rival
a4c2778b8d Fix keyboard avoidance on touchscreens for Ask AI tab (#7670)
* Fix keyboard avoidance on touchscreens for Ask AI tab
* Add placementId for in-app analytics

Don't show in changelog
2025-06-24 15:43:08 +02:00
D8H
f26e56c3bf Fix resource not loading when a custom object has both a variant and children overriding (#7668)
Don't show in changelog
2025-06-24 14:25:58 +02:00
Florian Rival
f5f9944fc4 Upgade m4pro.medium and XCode for CircleCI macOS build (#7669)
* This reduces total build time by ~40%.
2025-06-24 11:45:29 +02:00
D8H
9467caf1e9 Fix changing of variant not being applied at hot-reload (#7666) 2025-06-24 10:41:57 +02:00
Florian Rival
00376f39d5 Fix formatting and macOS CI build uploads
Don't show in changelog
2025-06-24 09:47:54 +02:00
Florian Rival
40b6a34dc5 Improve AI agent style
Don't show in changelog
2025-06-24 01:18:03 +02:00
Florian Rival
17d2b8c2c2 Improve the style of the text input for the AI agent/chat
Don't show in changelog
2025-06-23 23:41:29 +02:00
Florian Rival
935af42d23 Fix error when positioning instances in the AI agent
+ tentative fix for failing upload of macOS builds on the CI

Don't show in changelog
2025-06-23 20:05:26 +02:00
Florian Rival
d4a8d468cb Improve AI scene instance creation (#7667) 2025-06-23 18:52:39 +02:00
Florian Rival
b16099aee0 Bump newIDE version 2025-06-23 12:51:33 +02:00
github-actions[bot]
c17b918a43 Update translations [skip ci] (#7663)
Co-authored-by: D8H <2611977+D8H@users.noreply.github.com>
2025-06-23 09:42:28 +02:00
D8H
d58e8c7ef9 Fix hot-reload when custom objects are in an external layout (#7664) 2025-06-21 13:51:37 +02:00
Florian Rival
ddd6b6e3a8 Open a new AI chat when clicking on button to try the AI agent 2025-06-21 12:22:54 +02:00
Florian Rival
e629c132ea Improve opening of scene and events tab for AI
Don't show in changelog
2025-06-20 18:01:44 +02:00
github-actions[bot]
b80e03f153 Update translations [skip ci] (#7660)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-06-20 15:46:09 +02:00
Florian Rival
11e36ff3f1 Fix CI not failing if macOS build upload failed 2025-06-20 15:16:03 +02:00
Florian Rival
22de356413 Improve animation names support for AI and add retry for macOS build deployment
Don't show in changelog
2025-06-20 12:56:46 +02:00
Florian Rival
caefa04fbe Give AI the names of the animations of animatable objects
Don't show in changelog
2025-06-20 11:53:13 +02:00
Florian Rival
cf2e7d67d7 Improve robustness of AI event generation
Don't show in changelog
2025-06-20 11:34:12 +02:00
D8H
685e444b2d Comply variants when an extension is updated for an asset (#7661)
- Don't show in changelog
2025-06-20 10:31:42 +02:00
Florian Rival
a9c1045afd Avoid extra scrollbar in dialog to create a project with AI
Don't show in changelog
2025-06-18 14:56:48 +02:00
Florian Rival
24e0d37583 Improve draggable behavior condition descriptions 2025-06-18 12:56:38 +02:00
Florian Rival
d44997d372 Fix AI agent unable to change boolean properties 2025-06-18 00:09:45 +02:00
Florian Rival
062aa888f8 Disable AI agent chat when started for another project
Don't show in changelog
2025-06-17 18:07:26 +02:00
github-actions[bot]
de4c2ae4ad Update translations (#7633)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2025-06-17 17:24:50 +02:00
Florian Rival
29ad7308c3 Introduce an experimental AI agent (#7659)
- This is an AI agent that takes a request and takes actions on a project: it can create scenes, find and create objects, add, remove behaviors, modify them, put instances on the scene, create or modify events, and more to come (layers, setup leaderboards, etc...).
- It's still in beta and there is room for improvement on many things, but is already useful for prototyping and learning - beginners notably are able to see what the AI can do and learn the concepts of GDevelop. For intermediate and power users, it's useful to try new things, or get things done while working on something else.
- Experiment with it and always make backup of your project before starting - in the future restoration points will be added to go back to a previous state if the result is not good or broken.
2025-06-17 16:25:03 +02:00
D8H
19b21c280e Fix a case where neither the variant nor the children are shown in the object editor (#7658)
- Don't show in changelog
2025-06-16 19:06:55 +02:00
Florian Rival
fbfe8b246a Fix broken scroll to current search result in the Events Sheet
Don't show in changelog
2025-06-15 20:51:42 +02:00
Florian Rival
73f66eb51f Fix flickering when switching between events editors (#7655)
- Thanks @necaTecnico for the investigation and first version of the fix
2025-06-12 15:47:13 +02:00
260 changed files with 14314 additions and 3269 deletions

View File

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

View File

@@ -0,0 +1,130 @@
# AI Project Restore Implementation
## Overview
This implementation provides automatic project saving and restoration capabilities for AI agent requests, leveraging GDevelop's existing cloud save features and version history system.
## Key Features
### 1. Automatic Project Save Before AI Agent Requests
- When a user starts a new AI agent request, the project is automatically saved to create a cloud version
- This only works for cloud projects (projects saved to GDevelop Cloud)
- The current version ID is captured and stored with the AI request
### 2. Version-Based Restoration
- Uses GDevelop's existing cloud version system instead of custom serialization
- Leverages the `onOpenCloudProjectOnSpecificVersion` function from MainFrame
- Provides seamless restoration to the exact state before AI agent modifications
### 3. Smart UI Integration
- Restore button appears at the top of AI agent chats that have a stored initial version
- Only visible for cloud projects with stored version information
- Button shows loading state during restoration process
## Implementation Details
### Backend API Changes
- Extended `AiRequest` type to include `initialProjectVersionId?: string | null`
- This field stores the cloud project version ID captured before starting the agent
### Frontend Changes
#### 1. AskAiEditorContainer.js
- Added `onOpenCloudProjectOnSpecificVersion` prop to enable version restoration
- Modified AI request creation to save project and capture version ID for cloud projects
- Added `onRestoreInitialProject` callback that uses cloud version system
- Only attempts version capture for cloud projects (`storageProvider.internalName === 'Cloud'`)
#### 2. AiRequestChat/index.js
- Added restore button UI at the top of agent chats
- Added `isCloudProject` prop to control button visibility
- Added loading state for restore operation
- Proper error handling during restoration
#### 3. EditorFunctions/index.js
- Extended `EditorCallbacks` type to include optional `onSave` function
- Enables AI components to trigger project saves when needed
#### 4. MainFrame/index.js
- Added `onOpenCloudProjectOnSpecificVersion` to editor props
- This connects the AI editor to the existing version restoration system
## User Experience
### Starting an Agent Request
1. User opens AI agent and submits a request
2. System automatically saves the current project (creates a new version)
3. Version ID is stored with the AI request for later restoration
4. AI agent proceeds with modifications
### Restoring to Initial State
1. User sees "Click here to restore the project as it was at the beginning" button
2. Clicking the button triggers cloud version restoration
3. Project is restored to the exact state before AI agent started
4. All changes made by the AI agent are discarded
## Technical Advantages
### 1. Leverages Existing Infrastructure
- Uses GDevelop's mature cloud save and version system
- No custom serialization/deserialization code needed
- Inherits all cloud storage reliability and error handling
### 2. Scalable and Reliable
- Cloud versions are professionally managed and backed up
- No local storage limitations or browser storage issues
- Consistent across different devices and sessions
### 3. Version History Integration
- Restored versions appear in the project's version history
- Users can access version history features for AI-generated content
- Seamless integration with existing version management workflow
## Limitations and Considerations
### 1. Cloud Projects Only
- Feature only works for projects saved to GDevelop Cloud
- Local projects cannot use this restoration feature
- Clear messaging is provided when feature is unavailable
### 2. Version Storage
- Relies on cloud project version creation during save
- Version IDs are stored locally in the AI request metadata
- If local storage is cleared, version reference may be lost
### 3. Network Dependency
- Restoration requires internet connection for cloud access
- Standard cloud storage network limitations apply
## Error Handling
### 1. Save Failures
- If initial save fails, AI request continues without version storage
- User is informed that restoration won't be available
- Graceful degradation ensures AI functionality remains available
### 2. Restoration Failures
- Comprehensive error logging for debugging
- UI provides feedback during restoration process
- Button disabled during restoration to prevent conflicts
### 3. Non-Cloud Projects
- Restore functionality hidden for non-cloud projects
- Clear console warnings when attempting unsupported operations
- No impact on existing AI functionality for local projects
## Future Enhancements
### 1. Backend Integration
- Could extend backend API to store `initialProjectVersionId` server-side
- Would enable restoration across sessions and devices
- Currently relies on local client-side storage
### 2. Local Project Support
- Could implement local project snapshots using browser storage
- Would require custom serialization for non-cloud projects
- Currently prioritizes cloud projects for reliability
### 3. Enhanced UI
- Could add confirmation dialogs for restoration
- Might include preview of changes before restoration
- Could integrate with version history UI components

View File

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

View File

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

View File

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

View File

@@ -37,8 +37,8 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.SetIcon("res/actions/position24_black.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Angle"))
.SetIcon("res/actions/direction24_black.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Size"))
.SetIcon("res/actions/scale24_black.png");
extension.AddInstructionOrExpressionGroupMetadata(_("Size")).SetIcon(
"res/actions/scale24_black.png");
gd::ObjectMetadata& obj = extension.AddObject<gd::ObjectConfiguration>(
"", _("Base object"), _("Base object"), "res/objeticon24.png");
@@ -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",
@@ -835,14 +838,13 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
.MarkAsAdvanced()
.SetRelevantForLayoutEventsOnly();
obj.AddAction(
"PushBooleanToObjectVariable",
_("Add value to object array variable"),
_("Adds a boolean to the end of an object array variable."),
_("Add value _PARAM2_ to array variable _PARAM1_ of _PARAM0_"),
_("Variables Arrays and structures"),
"res/actions/var24.png",
"res/actions/var.png")
obj.AddAction("PushBooleanToObjectVariable",
_("Add value to object array variable"),
_("Adds a boolean to the end of an object array variable."),
_("Add value _PARAM2_ to array variable _PARAM1_ of _PARAM0_"),
_("Variables Arrays and structures"),
"res/actions/var24.png",
"res/actions/var.png")
.AddParameter("object", _("Object"))
.AddParameter("objectvar", _("Array variable"))
.AddParameter("trueorfalse", _("Boolean to add"))
@@ -1268,7 +1270,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"));
@@ -1571,7 +1574,9 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension(
extension
.AddAction("Create",
_("Create an object"),
_("Create an object at specified position"),
_("Create an instance of the object at the specified position."
"The created object instance will be available for the next "
"actions and sub-events."),
_("Create object _PARAM1_ at position _PARAM2_;_PARAM3_ "
"(layer: _PARAM4_)"),
"",

View File

@@ -72,7 +72,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
extension
.AddExpression("normalize",
_("Normalize a value between `min` and `max` to a value between 0 and 1."),
_("Normalize a value between `min` and `max` to a value "
"between 0 and 1."),
_("Remap a value between 0 and 1."),
"",
"res/mathfunction.png")
@@ -124,7 +125,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
extension
.AddExpression("mod",
_("Modulo"),
_("x mod y"),
_("Compute \"x mod y\". GDevelop does NOT support the \% "
"operator. Use this mod(x, y) function instead."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("x (as in x mod y)"))
@@ -184,11 +186,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"));
extension
.AddExpression("asinh",
_("Arcsine"),
_("Arcsine"),
"",
"res/mathfunction.png")
.AddExpression(
"asinh", _("Arcsine"), _("Arcsine"), "", "res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -218,11 +217,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"));
extension
.AddExpression("cbrt",
_("Cube root"),
_("Cube root"),
"",
"res/mathfunction.png")
.AddExpression(
"cbrt", _("Cube root"), _("Cube root"), "", "res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -260,12 +256,13 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"), "", true);
extension
.AddExpression("cos",
_("Cosine"),
_("Cosine of an angle (in radian). "
"If you want to use degrees, use`ToRad`: `sin(ToRad(45))`."),
"",
"res/mathfunction.png")
.AddExpression(
"cos",
_("Cosine"),
_("Cosine of an angle (in radian). "
"If you want to use degrees, use`ToRad`: `sin(ToRad(45))`."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -293,29 +290,20 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"));
extension
.AddExpression("int",
_("Round"),
_("Round a number"),
"",
"res/mathfunction.png")
.AddExpression(
"int", _("Round"), _("Round a number"), "", "res/mathfunction.png")
.SetHidden()
.AddParameter("expression", _("Expression"));
extension
.AddExpression("rint",
_("Round"),
_("Round a number"),
"",
"res/mathfunction.png")
.AddExpression(
"rint", _("Round"), _("Round a number"), "", "res/mathfunction.png")
.SetHidden()
.AddParameter("expression", _("Expression"));
extension
.AddExpression("round",
_("Round"),
_("Round a number"),
"",
"res/mathfunction.png")
.AddExpression(
"round", _("Round"), _("Round a number"), "", "res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -324,8 +312,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
_("Round a number to the Nth decimal place"),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Expression"))
.AddParameter("expression", _("Expression"), "", true);
.AddParameter("expression", _("Number to Round"))
.AddParameter("expression", _("Decimal Places"), "", true);
extension
.AddExpression("exp",
@@ -336,19 +324,13 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"));
extension
.AddExpression("log",
_("Logarithm"),
_("Logarithm"),
"",
"res/mathfunction.png")
.AddExpression(
"log", _("Logarithm"), _("Logarithm"), "", "res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
.AddExpression("ln",
_("Logarithm"),
_("Logarithm"),
"",
"res/mathfunction.png")
.AddExpression(
"ln", _("Logarithm"), _("Logarithm"), "", "res/mathfunction.png")
.SetHidden()
.AddParameter("expression", _("Expression"));
@@ -387,11 +369,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("The exponent (n in x^n)"));
extension
.AddExpression("sec",
_("Secant"),
_("Secant"),
"",
"res/mathfunction.png")
.AddExpression(
"sec", _("Secant"), _("Secant"), "", "res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -403,12 +382,13 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"));
extension
.AddExpression("sin",
_("Sine"),
_("Sine of an angle (in radian). "
"If you want to use degrees, use`ToRad`: `sin(ToRad(45))`."),
"",
"res/mathfunction.png")
.AddExpression(
"sin",
_("Sine"),
_("Sine of an angle (in radian). "
"If you want to use degrees, use`ToRad`: `sin(ToRad(45))`."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -428,12 +408,13 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("Expression"));
extension
.AddExpression("tan",
_("Tangent"),
_("Tangent of an angle (in radian). "
"If you want to use degrees, use`ToRad`: `tan(ToRad(45))`."),
"",
"res/mathfunction.png")
.AddExpression(
"tan",
_("Tangent"),
_("Tangent of an angle (in radian). "
"If you want to use degrees, use`ToRad`: `tan(ToRad(45))`."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Expression"));
extension
@@ -463,26 +444,28 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
.AddParameter("expression", _("x (in a+(b-a)*x)"));
extension
.AddExpression("XFromAngleAndDistance",
_("X position from angle and distance"),
_("Compute the X position when given an angle and distance "
"relative to the origin (0;0). This is also known as "
"getting the cartesian coordinates of a 2D vector, using "
"its polar coordinates."),
"",
"res/mathfunction.png")
.AddExpression(
"XFromAngleAndDistance",
_("X position from angle and distance"),
_("Compute the X position when given an angle and distance "
"relative to the origin (0;0). This is also known as "
"getting the cartesian coordinates of a 2D vector, using "
"its polar coordinates."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Angle, in degrees"))
.AddParameter("expression", _("Distance"));
extension
.AddExpression("YFromAngleAndDistance",
_("Y position from angle and distance"),
_("Compute the Y position when given an angle and distance "
"relative to the origin (0;0). This is also known as "
"getting the cartesian coordinates of a 2D vector, using "
"its polar coordinates."),
"",
"res/mathfunction.png")
.AddExpression(
"YFromAngleAndDistance",
_("Y position from angle and distance"),
_("Compute the Y position when given an angle and distance "
"relative to the origin (0;0). This is also known as "
"getting the cartesian coordinates of a 2D vector, using "
"its polar coordinates."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Angle, in degrees"))
.AddParameter("expression", _("Distance"));
@@ -497,7 +480,8 @@ BuiltinExtensionsImplementer::ImplementsMathematicalToolsExtension(
extension
.AddExpression("lerpAngle",
_("Lerp (Linear interpolation) between two angles"),
_("Linearly interpolates between two angles (in degrees) by taking the shortest direction around the circle."),
_("Linearly interpolates between two angles (in degrees) "
"by taking the shortest direction around the circle."),
"",
"res/mathfunction.png")
.AddParameter("expression", _("Starting angle, in degrees"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,6 @@ namespace gdjs {
objectData: gdjs.Object3DData & gdjs.CustomObjectConfiguration
) {
super(parent, objectData);
this._renderer.reinitialize(this, parent);
}
protected override _createRender() {

View File

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

View File

@@ -14,6 +14,7 @@ describe('gdjs.AnchorRuntimeBehavior', () => {
effects: [],
content: {},
childrenContent: {},
isInnerAreaFollowingParentSize: false,
});
runtimeScene.addObject(customObject);
customObject.setPosition(500, 250);

View File

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

View File

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

View File

@@ -818,7 +818,7 @@ module.exports = {
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('yesorno', _('Treat as bullet?'), '', false)
.addParameter('yesorno', _('Treat as bullet'), '', false)
.setDefaultValue('false')
.getCodeExtraInformation()
.setFunctionName('setBullet');
@@ -852,7 +852,7 @@ module.exports = {
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('yesorno', _('Fixed rotation?'), '', false)
.addParameter('yesorno', _('Fixed rotation'), '', false)
.setDefaultValue('false')
.getCodeExtraInformation()
.setFunctionName('setFixedRotation');
@@ -886,7 +886,7 @@ module.exports = {
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('yesorno', _('Can sleep?'), '', false)
.addParameter('yesorno', _('Can sleep'), '', false)
.setDefaultValue('true')
.getCodeExtraInformation()
.setFunctionName('setSleepingAllowed');
@@ -1296,7 +1296,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Layer (1 - 16)'))
.addParameter('yesorno', _('Enable?'), '', false)
.addParameter('yesorno', _('Enable'), '', false)
.setDefaultValue('true')
.getCodeExtraInformation()
.setFunctionName('enableLayer');
@@ -1332,7 +1332,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Mask (1 - 16)'))
.addParameter('yesorno', _('Enable?'), '', false)
.addParameter('yesorno', _('Enable'), '', false)
.setDefaultValue('true')
.getCodeExtraInformation()
.setFunctionName('enableMask');
@@ -2409,7 +2409,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Joint ID'))
.addParameter('yesorno', _('Enable?'))
.addParameter('yesorno', _('Enable'))
.getCodeExtraInformation()
.setFunctionName('enableRevoluteJointLimits');
@@ -2488,7 +2488,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Joint ID'))
.addParameter('yesorno', _('Enable?'))
.addParameter('yesorno', _('Enable'))
.getCodeExtraInformation()
.setFunctionName('enableRevoluteJointMotor');
@@ -2727,7 +2727,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Joint ID'))
.addParameter('yesorno', _('Enable?'))
.addParameter('yesorno', _('Enable'))
.getCodeExtraInformation()
.setFunctionName('enablePrismaticJointLimits');
@@ -2806,7 +2806,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Joint ID'))
.addParameter('yesorno', _('Enable?'))
.addParameter('yesorno', _('Enable'))
.getCodeExtraInformation()
.setFunctionName('enablePrismaticJointMotor');
@@ -3486,7 +3486,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics2Behavior')
.addParameter('expression', _('Joint ID'))
.addParameter('yesorno', _('Enable?'))
.addParameter('yesorno', _('Enable'))
.getCodeExtraInformation()
.setFunctionName('enableWheelJointMotor');

View File

@@ -274,7 +274,7 @@ module.exports = {
.setLabel('Fixed Rotation')
.setDescription(
_(
"If enabled, the object won't rotate and will stay at the same angle. Useful for characters for example."
"If enabled, the object won't rotate and will stay at the same angle."
)
)
.setGroup(_('Movement'));
@@ -845,7 +845,7 @@ module.exports = {
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.addParameter('yesorno', _('Treat as bullet?'), '', false)
.addParameter('yesorno', _('Treat as bullet'), '', false)
.setDefaultValue('false')
.getCodeExtraInformation()
.setFunctionName('setBullet');
@@ -870,7 +870,7 @@ module.exports = {
'SetFixedRotation',
_('Fixed rotation'),
_(
"Enable or disable an object fixed rotation. If enabled the object won't be able to rotate."
"Enable or disable an object fixed rotation. If enabled the object won't be able to rotate. This action has no effect on characters."
),
_('Set _PARAM0_ fixed rotation: _PARAM2_'),
_('Dynamics'),
@@ -879,7 +879,7 @@ module.exports = {
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.addParameter('yesorno', _('Fixed rotation?'), '', false)
.addParameter('yesorno', _('Fixed rotation'), '', false)
.setDefaultValue('false')
.getCodeExtraInformation()
.setFunctionName('setFixedRotation');
@@ -1054,7 +1054,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.addParameter('expression', _('Layer (1 - 8)'))
.addParameter('yesorno', _('Enable?'), '', false)
.addParameter('yesorno', _('Enable'), '', false)
.setDefaultValue('true')
.getCodeExtraInformation()
.setFunctionName('enableLayer');
@@ -1090,7 +1090,7 @@ module.exports = {
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.addParameter('expression', _('Mask (1 - 8)'))
.addParameter('yesorno', _('Enable?'), '', false)
.addParameter('yesorno', _('Enable'), '', false)
.setDefaultValue('true')
.getCodeExtraInformation()
.setFunctionName('enableMask');
@@ -1270,7 +1270,7 @@ module.exports = {
.addParameter('expression', _('Application point on Z axis'))
.setParameterLongDescription(
_(
'Use `MassCenterX` and `MassCenterY` expressions to avoid any rotation.'
'Use `MassCenterX`, `MassCenterY` and `MassCenterZ` expressions to avoid any rotation.'
)
)
.getCodeExtraInformation()
@@ -1544,6 +1544,19 @@ module.exports = {
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.getCodeExtraInformation()
.setFunctionName('getMassCenterY');
aut
.addExpression(
'MassCenterZ',
_('Mass center Z'),
_('Mass center Z'),
'',
'JsPlatform/Extensions/physics3d.svg'
)
.addParameter('object', _('Object'), '', false)
.addParameter('behavior', _('Behavior'), 'Physics3DBehavior')
.getCodeExtraInformation()
.setFunctionName('getMassCenterZ');
}
// Collision
extension

View File

@@ -927,9 +927,7 @@ namespace gdjs {
const angularVelocityY = angularVelocity.GetY();
const angularVelocityZ = angularVelocity.GetZ();
let bodyID = this._body.GetID();
bodyInterface.RemoveBody(bodyID);
bodyInterface.DestroyBody(bodyID);
this.bodyUpdater.destroyBody();
this._contactsEndedThisFrame.length = 0;
this._contactsStartedThisFrame.length = 0;
this._currentContacts.length = 0;
@@ -938,7 +936,7 @@ namespace gdjs {
if (!this._body) {
return;
}
bodyID = this._body.GetID();
const bodyID = this._body.GetID();
bodyInterface.SetLinearVelocity(
bodyID,
this.getVec3(linearVelocityX, linearVelocityY, linearVelocityZ)

View File

@@ -733,7 +733,7 @@ namespace gdjs {
}
setWheelOffsetZ(wheelOffsetZ: float): void {
this._wheelOffsetY = wheelOffsetZ;
this._wheelOffsetZ = wheelOffsetZ;
this._updateWheels();
}
@@ -783,11 +783,11 @@ namespace gdjs {
}
hasFrontWheelDrive(): boolean {
return this._hasBackWheelDrive;
return this._hasFrontWheelDrive;
}
setFrontWheelDrive(hasFrontWheelDrive: boolean): void {
this._hasBackWheelDrive = hasFrontWheelDrive;
this._hasFrontWheelDrive = hasFrontWheelDrive;
this.invalidateShape();
}

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ namespace gdjs {
objectData: ObjectData & CustomObjectConfiguration
) {
super(parent, objectData);
this.getRenderer().reinitialize(this, parent);
}
protected override _createRender(): gdjs.CustomRuntimeObject2DRenderer {

View File

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

View File

@@ -811,6 +811,8 @@ namespace gdjs {
this._objectsCtor = new Hashtable();
this._allInstancesList = [];
this._instancesRemoved = [];
this._layersCameraCoordinates = {};
this._initialBehaviorSharedData = new Hashtable();
}
}
}

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ describe('gdjs.CustomRuntimeObject', function () {
name: 'MyCustomObject',
type: 'MyExtension::MyEventsBasedObject',
variant: '',
isInnerAreaFollowingParentSize: false,
variables: [],
behaviors: [],
effects: [],

View File

@@ -100,6 +100,7 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => {
effects: [],
content: {},
childrenContent: {},
isInnerAreaFollowingParentSize: false,
};
/** @type {LayerData} */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

@@ -0,0 +1,855 @@
// @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,
onRestoreInitialProject?: () => Promise<void>,
isCloudProject?: boolean,
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,
onRestoreInitialProject,
isCloudProject,
quota,
increaseQuotaOffering,
lastSendError,
price,
availableCredits,
hasOpenedProject,
editorFunctionCallResults,
onProcessFunctionCalls,
isAutoProcessingFunctionCalls,
setAutoProcessFunctionCalls,
i18n,
editorCallbacks,
}: Props,
ref
) => {
const [isRestoring, setIsRestoring] = React.useState(false);
// 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}>
{aiRequest &&
aiRequest.mode === 'agent' &&
aiRequest.initialProjectVersionId &&
onRestoreInitialProject &&
isCloudProject && (
<Paper background="dark" variant="outlined" style={{ marginBottom: 8 }}>
<Column>
<LineStackLayout
justifyContent="center"
alignItems="center"
>
<Text size="body" color="secondary" noMargin>
<Trans>Click here to restore the project as it was at the beginning</Trans>
</Text>
<RaisedButton
size="small"
color="secondary"
label={<Trans>Restore project</Trans>}
disabled={isRestoring}
onClick={async () => {
if (!onRestoreInitialProject) return;
setIsRestoring(true);
try {
await onRestoreInitialProject();
} catch (error) {
console.error('Error in restore button:', error);
} finally {
setIsRestoring(false);
}
}}
/>
</LineStackLayout>
</Column>
</Paper>
)}
<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>
);
}
);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@@ -27,13 +27,20 @@ import ThreeDotsMenu from '../../UI/CustomSvgIcons/ThreeDotsMenu';
import useAlertDialog from '../../UI/Alert/useAlertDialog';
import ExtensionInstallDialog from '../ExtensionStore/ExtensionInstallDialog';
import { getIDEVersion } from '../../Version';
import InAppTutorialContext from '../../InAppTutorial/InAppTutorialContext';
export const useExtensionUpdateAlertDialog = () => {
const { showConfirmation } = useAlertDialog();
const { currentlyRunningInAppTutorial } = React.useContext(
InAppTutorialContext
);
return async (
project: gdProject,
behaviorShortHeader: BehaviorShortHeader
): Promise<boolean> => {
if (currentlyRunningInAppTutorial) {
return false;
}
return await showConfirmation({
title: t`Extension update`,
message:

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,11 +49,15 @@ import ErrorBoundary from '../UI/ErrorBoundary';
import type { ObjectFolderOrObjectWithContext } from '../ObjectsList/EnumerateObjectFolderOrObject';
import LoaderModal from '../UI/LoaderModal';
import { AssetStoreNavigatorContext } from './AssetStoreNavigator';
import InAppTutorialContext from '../InAppTutorial/InAppTutorialContext';
const isDev = Window.isDev();
export const useExtensionUpdateAlertDialog = () => {
const { showConfirmation, showDeleteConfirmation } = useAlertDialog();
const { currentlyRunningInAppTutorial } = React.useContext(
InAppTutorialContext
);
return async ({
project,
outOfDateExtensionShortHeaders,
@@ -61,6 +65,9 @@ export const useExtensionUpdateAlertDialog = () => {
project: gdProject,
outOfDateExtensionShortHeaders: Array<ExtensionShortHeader>,
|}): Promise<string> => {
if (currentlyRunningInAppTutorial) {
return 'skip';
}
const breakingChanges = new Map<
ExtensionShortHeader,
Array<ExtensionChange>
@@ -172,14 +179,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 +203,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 +251,7 @@ export const useInstallAsset = ({
shouldUpdateExtension: extensionUpdateAction === 'update',
eventsFunctionsExtensionsState,
project,
onExtensionInstalled,
});
const isPrivate = isPrivateAsset(assetShortHeader);
const installOutput = isPrivate
@@ -242,6 +259,7 @@ export const useInstallAsset = ({
asset,
project,
objectsContainer,
requestedObjectName,
targetObjectFolderOrObject:
targetObjectFolderOrObjectWithContext &&
!targetObjectFolderOrObjectWithContext.global
@@ -252,6 +270,7 @@ export const useInstallAsset = ({
asset,
project,
objectsContainer,
requestedObjectName,
targetObjectFolderOrObject:
targetObjectFolderOrObjectWithContext &&
!targetObjectFolderOrObjectWithContext.global
@@ -300,6 +319,7 @@ type Props = {|
onCreateNewObject: (type: string) => void,
onObjectsAddedFromAssets: (Array<gdObject>) => void,
targetObjectFolderOrObjectWithContext?: ?ObjectFolderOrObjectWithContext,
onExtensionInstalled: (extensionNames: Array<string>) => void,
|};
function NewObjectDialog({
@@ -312,6 +332,7 @@ function NewObjectDialog({
onCreateNewObject,
onObjectsAddedFromAssets,
targetObjectFolderOrObjectWithContext,
onExtensionInstalled,
}: Props) {
const { isMobile } = useResponsiveWindowSize();
const {
@@ -367,9 +388,9 @@ function NewObjectDialog({
const showExtensionUpdateConfirmation = useExtensionUpdateAlertDialog();
const installAsset = useInstallAsset({
project,
objectsContainer,
resourceManagementProps,
targetObjectFolderOrObjectWithContext,
onExtensionInstalled,
});
const onInstallAsset = React.useCallback(
@@ -377,13 +398,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 +449,7 @@ function NewObjectDialog({
shouldUpdateExtension: extensionUpdateAction === 'update',
eventsFunctionsExtensionsState,
project,
onExtensionInstalled,
});
onCreateNewObject(enumeratedObjectMetadata.name);
@@ -446,6 +471,7 @@ function NewObjectDialog({
showExtensionUpdateConfirmation,
eventsFunctionsExtensionsState,
showAlert,
onExtensionInstalled,
]
);
@@ -660,6 +686,7 @@ function NewObjectDialog({
targetObjectFolderOrObjectWithContext={
targetObjectFolderOrObjectWithContext
}
onExtensionInstalled={onExtensionInstalled}
/>
)}
</>

View File

@@ -656,6 +656,7 @@ const PrivateAssetPackInformationPage = ({
analyticsMetadata: {
reason: 'Claim asset pack',
recommendedPlanId: 'gdevelop_gold',
placementId: 'claim-asset-pack',
},
filter: 'individual',
})

View File

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

View File

@@ -74,3 +74,12 @@ export const filterEnumeratedBehaviorMetadata = (
);
});
};
export const isBehaviorDefaultCapability = (
behaviorMetadata: gdBehaviorMetadata
) => {
return (
behaviorMetadata.getName().includes('Capability') ||
behaviorMetadata.getName() === 'Scene3D::Base3DBehavior'
);
};

View File

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

View File

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

View File

@@ -174,6 +174,7 @@ const LockedCourseChapterPreview = React.forwardRef<Props, HTMLDivElement>(
analyticsMetadata: {
reason: 'Unlock course chapter',
recommendedPlanId: 'gdevelop_silver',
placementId: 'unlock-course-chapter',
},
})
}

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

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

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

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -89,7 +89,7 @@ type Props = {|
eventsFunctionsExtension: gdEventsFunctionsExtension,
name: string
) => void,
onExtensionInstalled: (extensionName: string) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
|};
type State = {|

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,13 +67,15 @@ const ConditionsActionsColumns = (props: Props) => {
);
}
const conditionWidth =
getConditionWidthRatio(props.eventsSheetWidth) * props.eventsSheetWidth -
props.leftIndentWidth;
return (
<div style={styles.twoColumnsContainer} className={props.className}>
{props.renderConditionsList({
style: {
width: `${getConditionWidthRatio(props.eventsSheetWidth) *
props.eventsSheetWidth -
props.leftIndentWidth}px`,
width: `${conditionWidth}px`,
},
className: conditionsContainer,
})}

View File

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

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

View File

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

View File

@@ -56,7 +56,7 @@ type Props = {|
i18n: I18nType,
canPasteInstructions: boolean, // Unused
onPasteInstructions: () => void, // Unused
onExtensionInstalled: (extensionName: string) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
|};
/**

View File

@@ -91,7 +91,10 @@ import {
pasteInstructionsFromClipboardInInstructionsList,
} from './ClipboardKind';
import { useScreenType } from '../UI/Responsive/ScreenTypeMeasurer';
import { ResponsiveWindowMeasurer } from '../UI/Responsive/ResponsiveWindowMeasurer';
import {
type WindowSizeType,
useResponsiveWindowSize,
} from '../UI/Responsive/ResponsiveWindowMeasurer';
import { type UnsavedChanges } from '../MainFrame/UnsavedChangesContext';
import AuthenticatedUserContext, {
type AuthenticatedUser,
@@ -151,11 +154,12 @@ type Props = {|
unsavedChanges?: ?UnsavedChanges,
isActive: boolean,
hotReloadPreviewButtonProps: HotReloadPreviewButtonProps,
onExtensionInstalled: (extensionName: string) => void,
onExtensionInstalled: (extensionNames: Array<string>) => void,
|};
type ComponentProps = {|
...Props,
windowSize: WindowSizeType,
authenticatedUser: AuthenticatedUser,
preferences: Preferences,
tutorials: ?Array<Tutorial>,
@@ -236,6 +240,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
_eventSearcher: ?EventsSearcher;
_searchPanel: ?SearchPanelInterface;
_containerDiv = React.createRef<HTMLDivElement>();
_containerDivLastKnownSize = null;
_keyboardShortcuts = new KeyboardShortcuts({
isActive: () =>
!this.state.inlineEditing &&
@@ -352,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;
@@ -1444,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();
}
);
@@ -1499,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();
}
);
@@ -1653,10 +1687,14 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
this.deleteSelection({ deleteInstructions: false });
};
_ensureEventUnfolded = (cb: () => ?gdBaseEvent) => {
_ensureUnfoldedAndScrollTo = (cb: () => ?gdBaseEvent) => {
const event = cb();
if (event && this._eventsTree) {
this._eventsTree.unfoldForEvent(event);
const eventsTree = this._eventsTree;
if (event && eventsTree) {
eventsTree.unfoldForEvent(event);
setTimeout(() => {
eventsTree.scrollToRow(eventsTree.getEventRow(event));
}, 0);
}
};
@@ -1837,6 +1875,7 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
onCreateEventsFunction,
tutorials,
hotReloadPreviewButtonProps,
windowSize,
} = this.props;
if (!project) return null;
@@ -1873,222 +1912,225 @@ export class EventsSheetComponentWithoutHandle extends React.Component<
? this.state.editedParameter.eventContext.projectScopedContainersAccessor
: projectScopedContainersAccessor;
// Memorize the last size of the container div, that is used to render the events tree.
// When the events editor tab is hidden, the container div width/height are 0.
// In this case, we keep the previous known size to avoid trashing the layout and events tree
// memorized heights for events (which would create flickering when switching back
// to the events editor tab).
if (this._containerDiv.current) {
const width = this._containerDiv.current.clientWidth;
const height = this._containerDiv.current.clientHeight;
if (width > 0 && height > 0) {
this._containerDivLastKnownSize = {
width,
height,
};
}
}
return (
<>
<ResponsiveWindowMeasurer>
{({ windowSize }) => (
<EventsSearcher
key={events.ptr}
ref={eventSearcher => (this._eventSearcher = eventSearcher)}
events={events}
globalObjectsContainer={globalObjectsContainer}
objectsContainer={objectsContainer}
selection={this.state.selection}
project={project}
<EventsSearcher
key={events.ptr}
ref={eventSearcher => (this._eventSearcher = eventSearcher)}
events={events}
globalObjectsContainer={globalObjectsContainer}
objectsContainer={objectsContainer}
selection={this.state.selection}
project={project}
>
{({
eventsSearchResultEvents,
searchFocusOffset,
searchInEvents,
replaceInEvents,
goToPreviousSearchResult,
goToNextSearchResult,
}) => (
<div
id="events-editor"
data-active={isActive ? 'true' : undefined}
className="gd-events-sheet"
style={styles.container}
onKeyDown={this._keyboardShortcuts.onKeyDown}
onKeyUp={this._keyboardShortcuts.onKeyUp}
onDragOver={this._keyboardShortcuts.onDragOver}
ref={this._containerDiv}
tabIndex={0}
>
{({
eventsSearchResultEvents,
searchFocusOffset,
searchInEvents,
replaceInEvents,
goToPreviousSearchResult,
goToNextSearchResult,
}) => (
<div
id="events-editor"
data-active={isActive ? 'true' : undefined}
className="gd-events-sheet"
style={styles.container}
onKeyDown={this._keyboardShortcuts.onKeyDown}
onKeyUp={this._keyboardShortcuts.onKeyUp}
onDragOver={this._keyboardShortcuts.onDragOver}
ref={this._containerDiv}
tabIndex={0}
>
{isFunctionOnlyCallingItself && (
<Line>
<Column expand>
<AlertMessage kind="warning">
<Trans>
This function calls itself (it is "recursive").
Ensure this is expected and there is a proper
condition to stop it if necessary.
</Trans>
</AlertMessage>
</Column>
</Line>
)}
<EventsTree
ref={eventsTree => (this._eventsTree = eventsTree)}
key={events.ptr}
indentScale={preferences.values.eventsSheetIndentScale}
onScroll={this._ensureFocused}
events={events}
project={project}
scope={scope}
globalObjectsContainer={globalObjectsContainer}
objectsContainer={objectsContainer}
projectScopedContainersAccessor={
projectScopedContainersAccessor
}
selection={this.state.selection}
onInstructionClick={this.selectInstruction}
onInstructionDoubleClick={this.openInstructionEditor}
onInstructionContextMenu={this.openInstructionContextMenu}
onAddInstructionContextMenu={
this.openAddInstructionContextMenu
}
onAddNewInstruction={this.openInstructionEditor}
onPasteInstructions={
this.pasteInstructionsInInstructionsList
}
onMoveToInstruction={this.moveSelectionToInstruction}
onMoveToInstructionsList={
this.moveSelectionToInstructionsList
}
onParameterClick={this.openParameterEditor}
onVariableDeclarationClick={() => {
// Nothing to do.
}}
onVariableDeclarationDoubleClick={this.openVariablesEditor}
onEventClick={this.selectEvent}
onEventContextMenu={this.openEventContextMenu}
onAddNewEvent={(
eventType: string,
eventsList: gdEventsList
) => {
this.addNewEvent(eventType, {
eventsList,
indexInList: eventsList.getEventsCount(),
});
}}
onOpenExternalEvents={onOpenExternalEvents}
onOpenLayout={onOpenLayout}
searchResults={eventsSearchResultEvents}
searchFocusOffset={searchFocusOffset}
onEventMoved={this._onEventMoved}
onEndEditingEvent={this._onEndEditingStringEvent}
showObjectThumbnails={
preferences.values.eventsSheetShowObjectThumbnails
}
screenType={screenType}
windowSize={windowSize}
eventsSheetWidth={
this._containerDiv.current
? this._containerDiv.current.clientWidth
: 0
}
eventsSheetHeight={
this._containerDiv.current
? this._containerDiv.current.clientHeight
: 0
}
fontSize={preferences.values.eventsSheetZoomLevel}
preferences={preferences}
tutorials={tutorials}
/>
{this.state.showSearchPanel && (
<ErrorBoundary
componentTitle={<Trans>Search panel</Trans>}
scope="scene-events-search"
onClose={() => this._closeSearchPanel()}
>
<SearchPanel
ref={searchPanel => (this._searchPanel = searchPanel)}
onSearchInEvents={inputs =>
this._searchInEvents(searchInEvents, inputs)
}
onReplaceInEvents={inputs => {
this._replaceInEvents(replaceInEvents, inputs);
}}
resultsCount={
eventsSearchResultEvents
? eventsSearchResultEvents.length
: null
}
hasEventSelected={hasEventSelected(
this.state.selection
)}
onGoToPreviousSearchResult={() =>
this._ensureEventUnfolded(goToPreviousSearchResult)
}
onCloseSearchPanel={() => {
this._closeSearchPanel();
}}
onGoToNextSearchResult={() =>
this._ensureEventUnfolded(goToNextSearchResult)
}
searchFocusOffset={searchFocusOffset}
/>
</ErrorBoundary>
)}
<InlineParameterEditor
open={this.state.inlineEditing}
anchorEl={this.state.inlineEditingAnchorEl}
onRequestClose={() => {
this.closeParameterEditor(
/*shouldCancel=*/ preferences.values
.eventsSheetCancelInlineParameter === 'cancel'
);
}}
onApply={() => {
this.closeParameterEditor(/*shouldCancel=*/ false);
}}
project={project}
scope={scope}
globalObjectsContainer={globalObjectsContainer}
objectsContainer={objectsContainer}
projectScopedContainersAccessor={
editedParameterProjectScopedContainersAccessor
}
isCondition={this.state.editedParameter.isCondition}
instruction={this.state.editedParameter.instruction}
parameterIndex={this.state.editedParameter.parameterIndex}
onChange={value => {
const {
instruction,
parameterIndex,
} = this.state.editedParameter;
if (!instruction || !this.state.inlineEditing) {
// Unlikely to ever happen, but maybe a component could
// fire the "onChange" while the inline editor was just
// dismissed.
return;
}
instruction.setParameter(parameterIndex, value);
gd.VariableInstructionSwitcher.switchBetweenUnifiedInstructionIfNeeded(
project.getCurrentPlatform(),
editedParameterProjectScopedContainersAccessor.get(),
instruction
);
// Ask the component to re-render, so that the new parameter
// set for the instruction in the state
// is taken into account for the InlineParameterEditor.
this.forceUpdate();
if (this._searchPanel)
this._searchPanel.markSearchResultsDirty();
}}
resourceManagementProps={resourceManagementProps}
/>
<ContextMenu
ref={eventContextMenu =>
(this.eventContextMenu = eventContextMenu)
}
buildMenuTemplate={this._buildEventContextMenu}
/>
<ContextMenu
ref={instructionContextMenu =>
(this.instructionContextMenu = instructionContextMenu)
}
buildMenuTemplate={this._buildInstructionContextMenu}
/>
</div>
{isFunctionOnlyCallingItself && (
<Line>
<Column expand>
<AlertMessage kind="warning">
<Trans>
This function calls itself (it is "recursive"). Ensure
this is expected and there is a proper condition to stop
it if necessary.
</Trans>
</AlertMessage>
</Column>
</Line>
)}
</EventsSearcher>
{this._containerDivLastKnownSize && (
<EventsTree
ref={eventsTree => (this._eventsTree = eventsTree)}
key={events.ptr}
indentScale={preferences.values.eventsSheetIndentScale}
onScroll={this._ensureFocused}
events={events}
project={project}
scope={scope}
globalObjectsContainer={globalObjectsContainer}
objectsContainer={objectsContainer}
projectScopedContainersAccessor={
projectScopedContainersAccessor
}
selection={this.state.selection}
onInstructionClick={this.selectInstruction}
onInstructionDoubleClick={this.openInstructionEditor}
onInstructionContextMenu={this.openInstructionContextMenu}
onAddInstructionContextMenu={
this.openAddInstructionContextMenu
}
onAddNewInstruction={this.openInstructionEditor}
onPasteInstructions={this.pasteInstructionsInInstructionsList}
onMoveToInstruction={this.moveSelectionToInstruction}
onMoveToInstructionsList={
this.moveSelectionToInstructionsList
}
onParameterClick={this.openParameterEditor}
onVariableDeclarationClick={() => {
// Nothing to do.
}}
onVariableDeclarationDoubleClick={this.openVariablesEditor}
onEventClick={this.selectEvent}
onEventContextMenu={this.openEventContextMenu}
onAddNewEvent={(
eventType: string,
eventsList: gdEventsList
) => {
this.addNewEvent(eventType, {
eventsList,
indexInList: eventsList.getEventsCount(),
});
}}
onOpenExternalEvents={onOpenExternalEvents}
onOpenLayout={onOpenLayout}
searchResults={eventsSearchResultEvents}
searchFocusOffset={searchFocusOffset}
onEventMoved={this._onEventMoved}
onEndEditingEvent={this._onEndEditingStringEvent}
showObjectThumbnails={
preferences.values.eventsSheetShowObjectThumbnails
}
screenType={screenType}
windowSize={windowSize}
eventsSheetWidth={this._containerDivLastKnownSize.width}
eventsSheetHeight={this._containerDivLastKnownSize.height}
fontSize={preferences.values.eventsSheetZoomLevel}
preferences={preferences}
tutorials={tutorials}
/>
)}
{this.state.showSearchPanel && (
<ErrorBoundary
componentTitle={<Trans>Search panel</Trans>}
scope="scene-events-search"
onClose={() => this._closeSearchPanel()}
>
<SearchPanel
ref={searchPanel => (this._searchPanel = searchPanel)}
onSearchInEvents={inputs =>
this._searchInEvents(searchInEvents, inputs)
}
onReplaceInEvents={inputs => {
this._replaceInEvents(replaceInEvents, inputs);
}}
resultsCount={
eventsSearchResultEvents
? eventsSearchResultEvents.length
: null
}
hasEventSelected={hasEventSelected(this.state.selection)}
onGoToPreviousSearchResult={() =>
this._ensureUnfoldedAndScrollTo(goToPreviousSearchResult)
}
onCloseSearchPanel={() => {
this._closeSearchPanel();
}}
onGoToNextSearchResult={() =>
this._ensureUnfoldedAndScrollTo(goToNextSearchResult)
}
searchFocusOffset={searchFocusOffset}
/>
</ErrorBoundary>
)}
<InlineParameterEditor
open={this.state.inlineEditing}
anchorEl={this.state.inlineEditingAnchorEl}
onRequestClose={() => {
this.closeParameterEditor(
/*shouldCancel=*/ preferences.values
.eventsSheetCancelInlineParameter === 'cancel'
);
}}
onApply={() => {
this.closeParameterEditor(/*shouldCancel=*/ false);
}}
project={project}
scope={scope}
globalObjectsContainer={globalObjectsContainer}
objectsContainer={objectsContainer}
projectScopedContainersAccessor={
editedParameterProjectScopedContainersAccessor
}
isCondition={this.state.editedParameter.isCondition}
instruction={this.state.editedParameter.instruction}
parameterIndex={this.state.editedParameter.parameterIndex}
onChange={value => {
const {
instruction,
parameterIndex,
} = this.state.editedParameter;
if (!instruction || !this.state.inlineEditing) {
// Unlikely to ever happen, but maybe a component could
// fire the "onChange" while the inline editor was just
// dismissed.
return;
}
instruction.setParameter(parameterIndex, value);
gd.VariableInstructionSwitcher.switchBetweenUnifiedInstructionIfNeeded(
project.getCurrentPlatform(),
editedParameterProjectScopedContainersAccessor.get(),
instruction
);
// Ask the component to re-render, so that the new parameter
// set for the instruction in the state
// is taken into account for the InlineParameterEditor.
this.forceUpdate();
if (this._searchPanel)
this._searchPanel.markSearchResultsDirty();
}}
resourceManagementProps={resourceManagementProps}
/>
<ContextMenu
ref={eventContextMenu =>
(this.eventContextMenu = eventContextMenu)
}
buildMenuTemplate={this._buildEventContextMenu}
/>
<ContextMenu
ref={instructionContextMenu =>
(this.instructionContextMenu = instructionContextMenu)
}
buildMenuTemplate={this._buildInstructionContextMenu}
/>
</div>
)}
</ResponsiveWindowMeasurer>
</EventsSearcher>
{this._renderInstructionEditorDialog()}
{this.state.analyzedEventsContextResult && (
<EventsContextAnalyzerDialog
@@ -2188,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
@@ -2196,6 +2239,7 @@ const EventsSheet = (props, ref) => {
React.useImperativeHandle(ref, () => ({
updateToolbar,
onResourceExternallyChanged,
onEventsModifiedOutsideEditor,
}));
const component = React.useRef<?EventsSheetComponentWithoutHandle>(null);
@@ -2206,11 +2250,15 @@ 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);
const { tutorials } = React.useContext(TutorialContext);
const leaderboardsManager = React.useContext(LeaderboardContext);
const { windowSize } = useResponsiveWindowSize();
const shortcutMap = useShortcutMap();
return (
<EventsSheetComponentWithoutHandle
@@ -2220,6 +2268,7 @@ const EventsSheet = (props, ref) => {
tutorials={tutorials}
leaderboardsManager={leaderboardsManager}
shortcutMap={shortcutMap}
windowSize={windowSize}
{...props}
/>
);

View File

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

View File

@@ -326,6 +326,7 @@ const InviteHome = ({ cloudProjectId }: Props) => {
<GetSubscriptionCard
subscriptionDialogOpeningReason="Add collaborators on project"
recommendedPlanIdIfNoSubscription="gdevelop_startup"
placementId="invite-collaborators"
>
<Text>
<Trans>

View File

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

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