mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
73 Commits
refactor/l
...
feat/comma
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ec16740520 | ||
![]() |
e26e239b1c | ||
![]() |
d62ba2b9a0 | ||
![]() |
0e48539a99 | ||
![]() |
323a2b6c2f | ||
![]() |
8e4cccd562 | ||
![]() |
795795ba40 | ||
![]() |
4af86b36e5 | ||
![]() |
b00632a625 | ||
![]() |
d8a8e759a0 | ||
![]() |
6f23f76441 | ||
![]() |
36c03a054e | ||
![]() |
a6cd4b3c5d | ||
![]() |
81d63c41b6 | ||
![]() |
a924840228 | ||
![]() |
b013297c8e | ||
![]() |
313d60a315 | ||
![]() |
ca77a31037 | ||
![]() |
5adb2240d5 | ||
![]() |
9d42be3362 | ||
![]() |
5dd2f85796 | ||
![]() |
21201dec29 | ||
![]() |
7e844ae539 | ||
![]() |
08229cbe1d | ||
![]() |
78da8185e5 | ||
![]() |
96e9dd7c4b | ||
![]() |
7dbc687200 | ||
![]() |
7e1f2c6c97 | ||
![]() |
6de8b5503d | ||
![]() |
37cba12e4a | ||
![]() |
ea4c9ff3fa | ||
![]() |
cdd80bca9e | ||
![]() |
3293d24c36 | ||
![]() |
bf31781d7a | ||
![]() |
2762415729 | ||
![]() |
90a34bd7d7 | ||
![]() |
31364bc487 | ||
![]() |
42b23f0d29 | ||
![]() |
b485bd0007 | ||
![]() |
c7a1883e1b | ||
![]() |
701b1d54e4 | ||
![]() |
95864b41d8 | ||
![]() |
14a1a5d746 | ||
![]() |
eb5acecb84 | ||
![]() |
04b1309f50 | ||
![]() |
babf0153a1 | ||
![]() |
a7b27b4d2d | ||
![]() |
78c4f4b7a5 | ||
![]() |
96206aac29 | ||
![]() |
95a938d93b | ||
![]() |
0286ecd139 | ||
![]() |
297be166cd | ||
![]() |
e626acdd21 | ||
![]() |
bf1f33b57a | ||
![]() |
890719b292 | ||
![]() |
e4815a1cb6 | ||
![]() |
d51cc42a64 | ||
![]() |
39a41e486d | ||
![]() |
6e857629bf | ||
![]() |
67a81549d1 | ||
![]() |
5f1c879b4c | ||
![]() |
521cfe2f97 | ||
![]() |
e4cc5023f8 | ||
![]() |
5f983bef1c | ||
![]() |
3878e99d4f | ||
![]() |
c9086554e2 | ||
![]() |
69e207fab4 | ||
![]() |
bdc674eda3 | ||
![]() |
d28b0c650d | ||
![]() |
78681eaba2 | ||
![]() |
48fa608c17 | ||
![]() |
6acecae77d | ||
![]() |
0a30e1e870 |
@@ -13,6 +13,7 @@ orbs:
|
||||
aws-cli: circleci/aws-cli@2.0.6
|
||||
macos: circleci/macos@2.5.1 # For Rosetta (see below)
|
||||
node: circleci/node@5.2.0 # For a recent npm version (see below)
|
||||
win: circleci/windows@5.1.0
|
||||
jobs:
|
||||
# Build the **entire** app for macOS (including the GDevelop.js library).
|
||||
build-macos:
|
||||
@@ -46,9 +47,9 @@ jobs:
|
||||
# GDevelop.js dependencies
|
||||
- restore_cache:
|
||||
keys:
|
||||
- gd-macos-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}
|
||||
- gd-macos-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}-{{ checksum "GDJS/package-lock.json" }}
|
||||
# fallback to using the latest cache if no exact match is found
|
||||
- gd-macos-nodejs-dependencies---
|
||||
- gd-macos-nodejs-dependencies-
|
||||
|
||||
- run:
|
||||
name: Install GDevelop.js dependencies
|
||||
@@ -69,7 +70,8 @@ jobs:
|
||||
- newIDE/electron-app/node_modules
|
||||
- newIDE/app/node_modules
|
||||
- GDevelop.js/node_modules
|
||||
key: gd-macos-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}
|
||||
- GDJS/node_modules
|
||||
key: gd-macos-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}-{{ checksum "GDJS/package-lock.json" }}
|
||||
|
||||
# Build GDevelop IDE (seems like we need to allow Node.js to use more space than usual)
|
||||
# Note: Code signing is done using CSC_LINK (see https://www.electron.build/code-signing).
|
||||
@@ -118,9 +120,9 @@ jobs:
|
||||
|
||||
- restore_cache:
|
||||
keys:
|
||||
- gd-linux-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}
|
||||
- gd-linux-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}-{{ checksum "GDJS/package-lock.json" }}
|
||||
# fallback to using the latest cache if no exact match is found
|
||||
- gd-linux-nodejs-dependencies---
|
||||
- gd-linux-nodejs-dependencies-
|
||||
|
||||
# GDevelop IDE dependencies (using an exact version of GDevelop.js, built previously)
|
||||
- run:
|
||||
@@ -132,7 +134,8 @@ jobs:
|
||||
- newIDE/electron-app/node_modules
|
||||
- newIDE/app/node_modules
|
||||
- GDevelop.js/node_modules
|
||||
key: gd-linux-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}
|
||||
- GDJS/node_modules
|
||||
key: gd-linux-nodejs-dependencies-{{ checksum "newIDE/app/package.json" }}-{{ checksum "newIDE/electron-app/package.json" }}-{{ checksum "GDevelop.js/package.json" }}-{{ checksum "GDJS/package-lock.json" }}
|
||||
|
||||
# Build GDevelop IDE (seems like we need to allow Node.js to use more space than usual)
|
||||
- run:
|
||||
@@ -276,8 +279,7 @@ jobs:
|
||||
name: Deploy to S3 (specific commit)
|
||||
command: aws s3 sync Binaries/embuild/GDevelop.js s3://gdevelop-gdevelop.js/$(git rev-parse --abbrev-ref HEAD)/variant/debug-sanitizers/commit/$(git rev-parse HEAD)/
|
||||
|
||||
# Trigger AppVeyor build, which finishes building the Windows app
|
||||
# (using GDevelop.js built in a previous step).
|
||||
# Trigger AppVeyor build, which also does a Windows build (keep it for redundancy).
|
||||
trigger-appveyor-windows-build:
|
||||
docker:
|
||||
- image: cimg/node:16.13
|
||||
@@ -294,12 +296,186 @@ jobs:
|
||||
}" \
|
||||
-X POST https://ci.appveyor.com/api/builds
|
||||
|
||||
build-windows:
|
||||
executor:
|
||||
name: win/default
|
||||
size: medium
|
||||
working_directory: /home/circleci/project
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
# See https://www.ssl.com/how-to/how-to-integrate-esigner-cka-with-ci-cd-tools-for-automated-code-signing/
|
||||
#
|
||||
# This is necessary because of "signing to be FIPS-140 compliant". See
|
||||
# https://github.com/electron-userland/electron-builder/issues/6158
|
||||
#
|
||||
# Make sure to DISABLE "malware blocker" in SSL.com to avoid errors like:
|
||||
# Error information: "Error: SignerSign() failed." (-2146893821/0x80090003)
|
||||
name: Download and Unzip eSignerCKA Setup
|
||||
command: |
|
||||
|
||||
Invoke-WebRequest -OutFile eSigner_CKA_1.0.3.zip "https://www.ssl.com/download/ssl-com-esigner-cka-1-0-3"
|
||||
|
||||
Expand-Archive -Force eSigner_CKA_1.0.3.zip
|
||||
|
||||
Remove-Item eSigner_CKA_1.0.3.zip
|
||||
|
||||
Move-Item -Destination "eSigner_CKA_1.0.3.exe" -Path "eSigner_CKA_*\*.exe"
|
||||
- run:
|
||||
name: Setup eSignerCKA in Silent Mode
|
||||
command: |
|
||||
|
||||
mkdir -p "/home/circleci/project/eSignerCKA"
|
||||
|
||||
./eSigner_CKA_1.0.3.exe /CURRENTUSER /VERYSILENT /SUPPRESSMSGBOXES /DIR="/home/circleci/project/eSignerCKA" | Out-Null
|
||||
- run:
|
||||
name: Config Account Information on eSignerCKA
|
||||
command: |
|
||||
|
||||
/home/circleci/project/eSignerCKA/eSignerCKATool.exe config -mode product -user "$env:ESIGNER_USER_NAME" -pass "$env:ESIGNER_USER_PASSWORD" -totp "$env:ESIGNER_USER_TOTP" -key "/home/circleci/project/eSignerCKA/master.key" -r
|
||||
- run:
|
||||
name: Load Certificate into Windows Store
|
||||
command: |
|
||||
|
||||
/home/circleci/project/eSignerCKA/eSignerCKATool.exe unload
|
||||
|
||||
/home/circleci/project/eSignerCKA/eSignerCKATool.exe load
|
||||
- run:
|
||||
name: Select Certificate From Windows Store and Sign Sample File with SignTool
|
||||
command: |
|
||||
|
||||
$CodeSigningCert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
|
||||
|
||||
echo Certificate: $CodeSigningCert
|
||||
|
||||
- restore_cache:
|
||||
name: Restore node_modules cache
|
||||
keys:
|
||||
- v1-win-node-{{ checksum "newIDE/app/package-lock.json" }}-{{ checksum "newIDE/electron-app/package-lock.json" }}-{{ checksum "GDJS/package-lock.json" }}
|
||||
- v1-win-node-
|
||||
|
||||
- run:
|
||||
name: Install dependencies
|
||||
no_output_timeout: 25m
|
||||
# Remove package-lock.json because they seems to cause the npm install to be stuck. We should try again after re-generating them.
|
||||
# Also install setuptools as something requires distutils in electron-app, and it was removed in Python 3.12.
|
||||
# setuptools will make distutils available again (but we should migrate our packages probably).
|
||||
command: |
|
||||
pip install setuptools
|
||||
|
||||
cd newIDE\app
|
||||
|
||||
npm -v
|
||||
|
||||
Remove-Item package-lock.json
|
||||
|
||||
$Env:REQUIRES_EXACT_LIBGD_JS_VERSION = "true"
|
||||
|
||||
npm install
|
||||
|
||||
cd ..\electron-app
|
||||
|
||||
Remove-Item package-lock.json
|
||||
|
||||
npm install
|
||||
|
||||
cd ..\..
|
||||
|
||||
- save_cache:
|
||||
name: Save node_modules cache
|
||||
key: v1-win-node-{{ checksum "newIDE/app/package-lock.json" }}-{{ checksum "newIDE/electron-app/package-lock.json" }}-{{ checksum "GDJS/package-lock.json" }}
|
||||
paths:
|
||||
- newIDE/app/node_modules
|
||||
- newIDE/electron-app/node_modules
|
||||
- GDJS/node_modules
|
||||
|
||||
- run:
|
||||
name: Build NSIS executable (with code signing)
|
||||
command: |
|
||||
cd newIDE\electron-app
|
||||
|
||||
$CodeSigningCert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
|
||||
|
||||
echo Certificate: $CodeSigningCert
|
||||
|
||||
# Use a custom signtool path because of the signtool.exe bundled withy electron-builder not working for some reason.
|
||||
# Can also be found in versioned folders like "C:/Program Files (x86)/Windows Kits/10/bin/10.0.22000.0/x86/signtool.exe".
|
||||
# or "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x86\signtool.exe".
|
||||
|
||||
$Env:SIGNTOOL_PATH = "C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe"
|
||||
|
||||
# Extract thumbprint and subject name of the certificate (will be passed to electron-builder).
|
||||
|
||||
$Env:GD_SIGNTOOL_THUMBPRINT = $CodeSigningCert.Thumbprint
|
||||
|
||||
$Env:GD_SIGNTOOL_SUBJECT_NAME = ($CodeSigningCert.Subject -replace ", ?", "`n" | ConvertFrom-StringData).CN
|
||||
|
||||
# Build the nsis installer (signed: electron-builder will use SignTool.exe with the certificate)
|
||||
|
||||
node scripts/build.js --win nsis --publish=never
|
||||
|
||||
cd ..\..
|
||||
- run:
|
||||
name: Build AppX (without code signing)
|
||||
# Don't sign the appx (it will be signed by the Microsoft Store).
|
||||
command: |
|
||||
cd newIDE\electron-app
|
||||
|
||||
# Build the appx (not signed). Ensure all variables used for code signing are empty.
|
||||
|
||||
$Env:GD_SIGNTOOL_THUMBPRINT = ''
|
||||
|
||||
$Env:GD_SIGNTOOL_SUBJECT_NAME = ''
|
||||
|
||||
$Env:CSC_LINK = ''
|
||||
|
||||
$Env:CSC_KEY_PASSWORD = ''
|
||||
|
||||
node scripts/build.js --skip-app-build --win appx --publish=never
|
||||
|
||||
cd ..\..
|
||||
|
||||
- run:
|
||||
name: Clean binaries
|
||||
shell: cmd.exe
|
||||
command: |
|
||||
rmdir /s /q newIDE\electron-app\dist\win-unpacked
|
||||
|
||||
- run:
|
||||
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
|
||||
|
||||
# Upload artifacts (S3)
|
||||
- run:
|
||||
name: Deploy to S3 (specific commit)
|
||||
command: |
|
||||
aws s3 sync newIDE\electron-app\dist "s3://gdevelop-releases/$Env:CIRCLE_BRANCH/commit/$Env:CIRCLE_SHA1/"
|
||||
|
||||
- run:
|
||||
name: Deploy to S3 (latest)
|
||||
command: |
|
||||
aws s3 sync newIDE\electron-app\dist "s3://gdevelop-releases/$Env:CIRCLE_BRANCH/latest/"
|
||||
|
||||
# Upload artifacts (CircleCI)
|
||||
- store_artifacts:
|
||||
path: newIDE/electron-app/dist
|
||||
|
||||
workflows:
|
||||
gdevelop_js-wasm-extra-checks:
|
||||
jobs:
|
||||
- build-gdevelop_js-debug-sanitizers-and-extra-checks:
|
||||
# Extra checks are resource intensive so don't all run them.
|
||||
# Extra checks are resource intensive so don't always run them.
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
@@ -326,6 +502,14 @@ workflows:
|
||||
only:
|
||||
- master
|
||||
- /experimental-build.*/
|
||||
- build-windows:
|
||||
requires:
|
||||
- build-gdevelop_js-wasm-only
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- /experimental-build.*/
|
||||
- trigger-appveyor-windows-build:
|
||||
requires:
|
||||
- build-gdevelop_js-wasm-only
|
||||
|
@@ -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_,
|
||||
|
@@ -163,6 +163,21 @@ void LinkEvent::UnserializeFrom(gd::Project& project,
|
||||
// end of compatibility code
|
||||
}
|
||||
|
||||
vector<gd::String> LinkEvent::GetAllSearchableStrings() const {
|
||||
vector<gd::String> allSearchableStrings;
|
||||
|
||||
allSearchableStrings.push_back(target);
|
||||
|
||||
return allSearchableStrings;
|
||||
}
|
||||
|
||||
bool LinkEvent::ReplaceAllSearchableStrings(
|
||||
std::vector<gd::String> newSearchableString) {
|
||||
if (newSearchableString[0] == target) return false;
|
||||
SetTarget(newSearchableString[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LinkEvent::AcceptVisitor(gd::EventVisitor &eventVisitor) {
|
||||
return BaseEvent::AcceptVisitor(eventVisitor) ||
|
||||
eventVisitor.VisitLinkEvent(*this);
|
||||
|
@@ -109,6 +109,10 @@ class GD_CORE_API LinkEvent : public gd::BaseEvent {
|
||||
|
||||
virtual bool IsExecutable() const override { return true; };
|
||||
|
||||
virtual std::vector<gd::String> GetAllSearchableStrings() const override;
|
||||
virtual bool ReplaceAllSearchableStrings(
|
||||
std::vector<gd::String> newSearchableString) override;
|
||||
|
||||
virtual void SerializeTo(SerializerElement& element) const override;
|
||||
virtual void UnserializeFrom(gd::Project& project,
|
||||
const SerializerElement& element) override;
|
||||
|
@@ -286,6 +286,20 @@ class GD_CORE_API BaseEvent {
|
||||
* \brief True if the event should be folded in the events editor.
|
||||
*/
|
||||
bool IsFolded() const { return folded; }
|
||||
|
||||
/**
|
||||
* \brief Set the AI generated event ID.
|
||||
*/
|
||||
void SetAiGeneratedEventId(const gd::String& aiGeneratedEventId_) {
|
||||
aiGeneratedEventId = aiGeneratedEventId_;
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief Get the AI generated event ID.
|
||||
*/
|
||||
const gd::String& GetAiGeneratedEventId() const {
|
||||
return aiGeneratedEventId;
|
||||
}
|
||||
///@}
|
||||
|
||||
std::weak_ptr<gd::BaseEvent>
|
||||
@@ -304,6 +318,7 @@ class GD_CORE_API BaseEvent {
|
||||
bool disabled; ///< True if the event is disabled and must not be executed
|
||||
gd::String type; ///< Type of the event. Must be assigned at the creation.
|
||||
///< Used for saving the event for instance.
|
||||
gd::String aiGeneratedEventId; ///< When generated by an AI/external tool.
|
||||
|
||||
static gd::EventsList badSubEvents;
|
||||
static gd::VariablesContainer badLocalVariables;
|
||||
|
@@ -221,6 +221,8 @@ void EventsListSerialization::UnserializeEventsFrom(
|
||||
|
||||
event->SetDisabled(eventElem.GetBoolAttribute("disabled", false));
|
||||
event->SetFolded(eventElem.GetBoolAttribute("folded", false));
|
||||
event->SetAiGeneratedEventId(
|
||||
eventElem.GetStringAttribute("aiGeneratedEventId", ""));
|
||||
|
||||
list.InsertEvent(event, list.GetEventsCount());
|
||||
}
|
||||
@@ -236,6 +238,8 @@ void EventsListSerialization::SerializeEventsTo(const EventsList& list,
|
||||
if (event.IsDisabled())
|
||||
eventElem.SetAttribute("disabled", event.IsDisabled());
|
||||
if (event.IsFolded()) eventElem.SetAttribute("folded", event.IsFolded());
|
||||
if (!event.GetAiGeneratedEventId().empty())
|
||||
eventElem.SetAttribute("aiGeneratedEventId", event.GetAiGeneratedEventId());
|
||||
eventElem.AddChild("type").SetValue(event.GetType());
|
||||
|
||||
event.SerializeTo(eventElem);
|
||||
|
@@ -779,6 +779,26 @@ gd::String PlatformExtension::GetBehaviorFullType(
|
||||
return extensionName + separator + behaviorName;
|
||||
}
|
||||
|
||||
gd::String PlatformExtension::GetExtensionFromFullBehaviorType(
|
||||
const gd::String& type) {
|
||||
const auto separatorIndex =
|
||||
type.find(PlatformExtension::GetNamespaceSeparator());
|
||||
if (separatorIndex == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
return type.substr(0, separatorIndex);
|
||||
}
|
||||
|
||||
gd::String PlatformExtension::GetBehaviorNameFromFullBehaviorType(
|
||||
const gd::String& type) {
|
||||
const auto separatorIndex =
|
||||
type.find(PlatformExtension::GetNamespaceSeparator());
|
||||
if (separatorIndex == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
return type.substr(separatorIndex + 2);
|
||||
}
|
||||
|
||||
gd::String PlatformExtension::GetObjectEventsFunctionFullType(
|
||||
const gd::String& extensionName,
|
||||
const gd::String& objectName,
|
||||
|
@@ -651,6 +651,10 @@ class GD_CORE_API PlatformExtension {
|
||||
static gd::String GetBehaviorFullType(const gd::String& extensionName,
|
||||
const gd::String& behaviorName);
|
||||
|
||||
static gd::String GetExtensionFromFullBehaviorType(const gd::String& type);
|
||||
|
||||
static gd::String GetBehaviorNameFromFullBehaviorType(const gd::String& type);
|
||||
|
||||
static gd::String GetObjectEventsFunctionFullType(
|
||||
const gd::String& extensionName,
|
||||
const gd::String& objectName,
|
||||
|
@@ -1781,6 +1781,14 @@ void WholeProjectRefactorer::DoRenameBehavior(
|
||||
projectBrowser.ExposeFunctions(project, behaviorParameterRenamer);
|
||||
}
|
||||
|
||||
void WholeProjectRefactorer::UpdateBehaviorsSharedData(gd::Project &project) {
|
||||
for (std::size_t i = 0; i < project.GetLayoutsCount(); ++i) {
|
||||
gd::Layout &layout = project.GetLayout(i);
|
||||
|
||||
layout.UpdateBehaviorsSharedData(project);
|
||||
}
|
||||
}
|
||||
|
||||
void WholeProjectRefactorer::DoRenameObject(
|
||||
gd::Project &project, const gd::String &oldObjectType,
|
||||
const gd::String &newObjectType, const gd::ProjectBrowser &projectBrowser) {
|
||||
|
@@ -704,6 +704,16 @@ class GD_CORE_API WholeProjectRefactorer {
|
||||
static size_t GetLayoutAndExternalLayoutLayerInstancesCount(
|
||||
gd::Project &project, gd::Layout &layout, const gd::String &layerName);
|
||||
|
||||
/**
|
||||
* This ensures that the scenes had an instance of shared data for
|
||||
* every behavior of every object that can be used on the scene
|
||||
* (i.e. the objects of the scene and the global objects)
|
||||
*
|
||||
* Must be called when a behavior have been added/deleted
|
||||
* from a global object or an object has been made global.
|
||||
*/
|
||||
static void UpdateBehaviorsSharedData(gd::Project &project);
|
||||
|
||||
virtual ~WholeProjectRefactorer(){};
|
||||
|
||||
private:
|
||||
|
@@ -83,6 +83,9 @@ void EventsFunctionsExtension::SerializeTo(SerializerElement& element, bool isEx
|
||||
element.SetAttribute("iconUrl", iconUrl);
|
||||
element.SetAttribute("helpPath", helpPath);
|
||||
element.SetAttribute("gdevelopVersion", gdevelopVersion);
|
||||
if (changelog.GetChangesCount() > 0) {
|
||||
changelog.SerializeTo(element.AddChild("changelog"));
|
||||
}
|
||||
auto& dependenciesElement = element.AddChild("dependencies");
|
||||
dependenciesElement.ConsiderAsArray();
|
||||
for (auto& dependency : dependencies)
|
||||
@@ -139,6 +142,9 @@ void EventsFunctionsExtension::UnserializeExtensionDeclarationFrom(
|
||||
iconUrl = element.GetStringAttribute("iconUrl");
|
||||
helpPath = element.GetStringAttribute("helpPath");
|
||||
gdevelopVersion = element.GetStringAttribute("gdevelopVersion");
|
||||
if (element.HasChild("changelog")) {
|
||||
changelog.UnserializeFrom(element.GetChild("changelog"));
|
||||
}
|
||||
|
||||
if (element.HasChild("origin")) {
|
||||
gd::String originName =
|
||||
|
@@ -12,9 +12,11 @@
|
||||
#include "GDCore/Project/EventsBasedBehavior.h"
|
||||
#include "GDCore/Project/EventsBasedObject.h"
|
||||
#include "GDCore/Project/EventsFunctionsContainer.h"
|
||||
#include "GDCore/Project/EventsFunctionsExtensionChangelog.h"
|
||||
#include "GDCore/Project/VariablesContainer.h"
|
||||
#include "GDCore/String.h"
|
||||
#include "GDCore/Tools/SerializableWithNameList.h"
|
||||
|
||||
namespace gd {
|
||||
class SerializerElement;
|
||||
class Project;
|
||||
@@ -406,6 +408,7 @@ class GD_CORE_API EventsFunctionsExtension {
|
||||
gd::String helpPath; ///< The relative path to the help for this extension in
|
||||
///< the documentation (or an absolute URL).
|
||||
gd::String gdevelopVersion;
|
||||
gd::EventsFunctionsExtensionChangelog changelog;
|
||||
gd::SerializableWithNameList<EventsBasedBehavior> eventsBasedBehaviors;
|
||||
gd::SerializableWithNameList<EventsBasedObject> eventsBasedObjects;
|
||||
std::vector<gd::DependencyMetadata> dependencies;
|
||||
|
105
Core/GDCore/Project/EventsFunctionsExtensionChangelog.h
Normal file
105
Core/GDCore/Project/EventsFunctionsExtensionChangelog.h
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* GDevelop Core
|
||||
* Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights
|
||||
* reserved. This project is released under the MIT License.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "GDCore/Serialization/SerializerElement.h"
|
||||
#include "GDCore/String.h"
|
||||
|
||||
namespace gd {
|
||||
|
||||
/**
|
||||
* @brief The change of a specific extension version (only the breaking
|
||||
* changes).
|
||||
*/
|
||||
class GD_CORE_API EventsFunctionsExtensionVersionChange {
|
||||
public:
|
||||
EventsFunctionsExtensionVersionChange(){};
|
||||
virtual ~EventsFunctionsExtensionVersionChange(){};
|
||||
|
||||
const gd::String &GetVersion() const { return version; };
|
||||
gd::EventsFunctionsExtensionVersionChange &
|
||||
SetVersion(const gd::String &version_) {
|
||||
version = version_;
|
||||
return *this;
|
||||
}
|
||||
|
||||
const gd::String &GetBreakingChangesDescription() const { return version; };
|
||||
gd::EventsFunctionsExtensionVersionChange &
|
||||
GetBreakingChangesDescription(const gd::String &breakingChangesDescription_) {
|
||||
breakingChangesDescription = breakingChangesDescription_;
|
||||
return *this;
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief Serialize the EventsFunctionsExtensionVersionChange to the specified
|
||||
* element
|
||||
*/
|
||||
void SerializeTo(gd::SerializerElement &element) const {
|
||||
element.SetAttribute("version", version);
|
||||
element.AddChild("breaking")
|
||||
.SetMultilineStringValue(breakingChangesDescription);
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief Load the EventsFunctionsExtensionVersionChange from the specified
|
||||
* element.
|
||||
*/
|
||||
void UnserializeFrom(const gd::SerializerElement &element) {
|
||||
version = element.GetStringAttribute("version");
|
||||
breakingChangesDescription =
|
||||
element.GetChild("breaking").GetMultilineStringValue();
|
||||
}
|
||||
|
||||
private:
|
||||
gd::String version;
|
||||
gd::String breakingChangesDescription;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief The changelog of an extension (only the breaking changes).
|
||||
*/
|
||||
class GD_CORE_API EventsFunctionsExtensionChangelog {
|
||||
public:
|
||||
EventsFunctionsExtensionChangelog(){};
|
||||
virtual ~EventsFunctionsExtensionChangelog(){};
|
||||
|
||||
/**
|
||||
* \brief Return the number of variants.
|
||||
*/
|
||||
std::size_t GetChangesCount() const { return versionChanges.size(); }
|
||||
|
||||
/**
|
||||
* \brief Serialize the EventsFunctionsExtensionChangelog to the specified
|
||||
* element
|
||||
*/
|
||||
void SerializeTo(gd::SerializerElement &element) const {
|
||||
element.ConsiderAsArray();
|
||||
for (const auto &versionChange : versionChanges) {
|
||||
versionChange.SerializeTo(element.AddChild(""));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* \brief Load the EventsFunctionsExtensionChangelog from the specified
|
||||
* element.
|
||||
*/
|
||||
void UnserializeFrom(const gd::SerializerElement &element) {
|
||||
versionChanges.clear();
|
||||
element.ConsiderAsArray();
|
||||
for (std::size_t i = 0; i < element.GetChildrenCount(); ++i) {
|
||||
gd::EventsFunctionsExtensionVersionChange versionChange;
|
||||
versionChange.UnserializeFrom(element.GetChild(i));
|
||||
versionChanges.push_back(versionChange);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<gd::EventsFunctionsExtensionVersionChange> versionChanges;
|
||||
};
|
||||
|
||||
} // namespace gd
|
@@ -81,6 +81,11 @@ class GD_CORE_API ObjectsContainersList {
|
||||
/**
|
||||
* \brief Return the container of the variables for the specified object or
|
||||
* group of objects.
|
||||
*
|
||||
* \warning In most cases, prefer to use other methods to access variables or use
|
||||
* ObjectVariableHelper::MergeVariableContainers if you know you're dealing with a group.
|
||||
* This is because the variables container of an object group does not exist and the one from
|
||||
* first object of the group will be returned.
|
||||
*/
|
||||
const gd::VariablesContainer* GetObjectOrGroupVariablesContainer(
|
||||
const gd::String& objectOrGroupName) const;
|
||||
|
@@ -21,14 +21,19 @@ void PropertyDescriptor::SerializeTo(SerializerElement& element) const {
|
||||
element.AddChild("unit").SetStringValue(measurementUnit.GetName());
|
||||
}
|
||||
element.AddChild("label").SetStringValue(label);
|
||||
element.AddChild("description").SetStringValue(description);
|
||||
element.AddChild("group").SetStringValue(group);
|
||||
SerializerElement& extraInformationElement =
|
||||
element.AddChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (const gd::String& information : extraInformation) {
|
||||
extraInformationElement.AddChild("").SetStringValue(information);
|
||||
if (!description.empty())
|
||||
element.AddChild("description").SetStringValue(description);
|
||||
if (!group.empty()) element.AddChild("group").SetStringValue(group);
|
||||
|
||||
if (!extraInformation.empty()) {
|
||||
SerializerElement& extraInformationElement =
|
||||
element.AddChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (const gd::String& information : extraInformation) {
|
||||
extraInformationElement.AddChild("").SetStringValue(information);
|
||||
}
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
element.AddChild("hidden").SetBoolValue(hidden);
|
||||
}
|
||||
@@ -59,16 +64,21 @@ void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) {
|
||||
: gd::MeasurementUnit::GetUndefined();
|
||||
}
|
||||
label = element.GetChild("label").GetStringValue();
|
||||
description = element.GetChild("description").GetStringValue();
|
||||
group = element.GetChild("group").GetStringValue();
|
||||
description = element.HasChild("description")
|
||||
? element.GetChild("description").GetStringValue()
|
||||
: "";
|
||||
group = element.HasChild("group") ? element.GetChild("group").GetStringValue()
|
||||
: "";
|
||||
|
||||
extraInformation.clear();
|
||||
const SerializerElement& extraInformationElement =
|
||||
element.GetChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (std::size_t i = 0; i < extraInformationElement.GetChildrenCount(); ++i)
|
||||
extraInformation.push_back(
|
||||
extraInformationElement.GetChild(i).GetStringValue());
|
||||
if (element.HasChild("extraInformation")) {
|
||||
const SerializerElement& extraInformationElement =
|
||||
element.GetChild("extraInformation");
|
||||
extraInformationElement.ConsiderAsArray();
|
||||
for (std::size_t i = 0; i < extraInformationElement.GetChildrenCount(); ++i)
|
||||
extraInformation.push_back(
|
||||
extraInformationElement.GetChild(i).GetStringValue());
|
||||
}
|
||||
|
||||
hidden = element.HasChild("hidden")
|
||||
? element.GetChild("hidden").GetBoolValue()
|
||||
|
@@ -116,48 +116,7 @@ namespace gdjs {
|
||||
|
||||
_updateLocalPositions() {
|
||||
const obj = this._object;
|
||||
this._centerSprite.position.x = obj._lBorder;
|
||||
this._centerSprite.position.y = obj._tBorder;
|
||||
|
||||
//Right
|
||||
this._borderSprites[0].position.x = obj._width - obj._rBorder;
|
||||
this._borderSprites[0].position.y = obj._tBorder;
|
||||
|
||||
//Top-right
|
||||
this._borderSprites[1].position.x =
|
||||
obj._width - this._borderSprites[1].width;
|
||||
this._borderSprites[1].position.y = 0;
|
||||
|
||||
//Top
|
||||
this._borderSprites[2].position.x = obj._lBorder;
|
||||
this._borderSprites[2].position.y = 0;
|
||||
|
||||
//Top-Left
|
||||
this._borderSprites[3].position.x = 0;
|
||||
this._borderSprites[3].position.y = 0;
|
||||
|
||||
//Left
|
||||
this._borderSprites[4].position.x = 0;
|
||||
this._borderSprites[4].position.y = obj._tBorder;
|
||||
|
||||
//Bottom-Left
|
||||
this._borderSprites[5].position.x = 0;
|
||||
this._borderSprites[5].position.y =
|
||||
obj._height - this._borderSprites[5].height;
|
||||
|
||||
//Bottom
|
||||
this._borderSprites[6].position.x = obj._lBorder;
|
||||
this._borderSprites[6].position.y = obj._height - obj._bBorder;
|
||||
|
||||
//Bottom-Right
|
||||
this._borderSprites[7].position.x =
|
||||
obj._width - this._borderSprites[7].width;
|
||||
this._borderSprites[7].position.y =
|
||||
obj._height - this._borderSprites[7].height;
|
||||
}
|
||||
|
||||
_updateSpritesAndTexturesSize() {
|
||||
const obj = this._object;
|
||||
this._centerSprite.width = Math.max(
|
||||
obj._width - obj._rBorder - obj._lBorder,
|
||||
0
|
||||
@@ -167,35 +126,107 @@ namespace gdjs {
|
||||
0
|
||||
);
|
||||
|
||||
let leftMargin = obj._lBorder;
|
||||
let rightMargin = obj._rBorder;
|
||||
if (this._centerSprite.width === 0 && obj._lBorder + obj._rBorder > 0) {
|
||||
leftMargin =
|
||||
(obj._width * obj._lBorder) / (obj._lBorder + obj._rBorder);
|
||||
rightMargin = obj._width - leftMargin;
|
||||
}
|
||||
let topMargin = obj._tBorder;
|
||||
let bottomMargin = obj._bBorder;
|
||||
if (this._centerSprite.height === 0 && obj._tBorder + obj._bBorder > 0) {
|
||||
topMargin =
|
||||
(obj._height * obj._tBorder) / (obj._tBorder + obj._bBorder);
|
||||
bottomMargin = obj._height - topMargin;
|
||||
}
|
||||
|
||||
//Right
|
||||
this._borderSprites[0].width = obj._rBorder;
|
||||
this._borderSprites[0].width = rightMargin;
|
||||
this._borderSprites[0].height = Math.max(
|
||||
obj._height - obj._tBorder - obj._bBorder,
|
||||
obj._height - topMargin - bottomMargin,
|
||||
0
|
||||
);
|
||||
|
||||
//Top
|
||||
this._borderSprites[2].height = obj._tBorder;
|
||||
this._borderSprites[2].height = topMargin;
|
||||
this._borderSprites[2].width = Math.max(
|
||||
obj._width - obj._rBorder - obj._lBorder,
|
||||
obj._width - rightMargin - leftMargin,
|
||||
0
|
||||
);
|
||||
|
||||
//Left
|
||||
this._borderSprites[4].width = obj._lBorder;
|
||||
this._borderSprites[4].width = leftMargin;
|
||||
this._borderSprites[4].height = Math.max(
|
||||
obj._height - obj._tBorder - obj._bBorder,
|
||||
obj._height - topMargin - bottomMargin,
|
||||
0
|
||||
);
|
||||
|
||||
//Bottom
|
||||
this._borderSprites[6].height = obj._bBorder;
|
||||
this._borderSprites[6].height = bottomMargin;
|
||||
this._borderSprites[6].width = Math.max(
|
||||
obj._width - obj._rBorder - obj._lBorder,
|
||||
obj._width - rightMargin - leftMargin,
|
||||
0
|
||||
);
|
||||
|
||||
//Top-right
|
||||
this._borderSprites[1].width = rightMargin;
|
||||
this._borderSprites[1].height = topMargin;
|
||||
|
||||
//Top-Left
|
||||
this._borderSprites[3].width = leftMargin;
|
||||
this._borderSprites[3].height = topMargin;
|
||||
|
||||
//Bottom-Left
|
||||
this._borderSprites[5].width = leftMargin;
|
||||
this._borderSprites[5].height = bottomMargin;
|
||||
|
||||
//Bottom-Right
|
||||
this._borderSprites[7].width = rightMargin;
|
||||
this._borderSprites[7].height = bottomMargin;
|
||||
|
||||
this._wasRendered = true;
|
||||
this._spritesContainer.cacheAsBitmap = false;
|
||||
|
||||
const leftBorder = leftMargin;
|
||||
const topBorder = topMargin;
|
||||
const rightBorder = obj._width - rightMargin;
|
||||
const bottomBorder = obj._height - bottomMargin;
|
||||
|
||||
this._centerSprite.position.x = leftBorder;
|
||||
this._centerSprite.position.y = topBorder;
|
||||
|
||||
//Right
|
||||
this._borderSprites[0].position.x = rightBorder;
|
||||
this._borderSprites[0].position.y = topBorder;
|
||||
|
||||
//Top-right
|
||||
this._borderSprites[1].position.x = rightBorder;
|
||||
this._borderSprites[1].position.y = 0;
|
||||
|
||||
//Top
|
||||
this._borderSprites[2].position.x = leftBorder;
|
||||
this._borderSprites[2].position.y = 0;
|
||||
|
||||
//Top-Left
|
||||
this._borderSprites[3].position.x = 0;
|
||||
this._borderSprites[3].position.y = 0;
|
||||
|
||||
//Left
|
||||
this._borderSprites[4].position.x = 0;
|
||||
this._borderSprites[4].position.y = topBorder;
|
||||
|
||||
//Bottom-Left
|
||||
this._borderSprites[5].position.x = 0;
|
||||
this._borderSprites[5].position.y = bottomBorder;
|
||||
|
||||
//Bottom
|
||||
this._borderSprites[6].position.x = leftBorder;
|
||||
this._borderSprites[6].position.y = bottomBorder;
|
||||
|
||||
//Bottom-Right
|
||||
this._borderSprites[7].position.x = rightBorder;
|
||||
this._borderSprites[7].position.y = bottomBorder;
|
||||
}
|
||||
|
||||
setTexture(
|
||||
@@ -340,7 +371,6 @@ namespace gdjs {
|
||||
)
|
||||
)
|
||||
);
|
||||
this._updateSpritesAndTexturesSize();
|
||||
this._updateLocalPositions();
|
||||
this.updatePosition();
|
||||
this._wrapperContainer.pivot.x = this._object._width / 2;
|
||||
@@ -349,14 +379,12 @@ namespace gdjs {
|
||||
|
||||
updateWidth(): void {
|
||||
this._wrapperContainer.pivot.x = this._object._width / 2;
|
||||
this._updateSpritesAndTexturesSize();
|
||||
this._updateLocalPositions();
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
updateHeight(): void {
|
||||
this._wrapperContainer.pivot.y = this._object._height / 2;
|
||||
this._updateSpritesAndTexturesSize();
|
||||
this._updateLocalPositions();
|
||||
this.updatePosition();
|
||||
}
|
||||
|
@@ -36,6 +36,7 @@ void PlatformerObjectBehavior::InitializeContent(
|
||||
behaviorContent.SetAttribute("yGrabOffset", 0);
|
||||
behaviorContent.SetAttribute("xGrabTolerance", 10);
|
||||
behaviorContent.SetAttribute("useLegacyTrajectory", false);
|
||||
behaviorContent.SetAttribute("useRepeatedJump", false);
|
||||
behaviorContent.SetAttribute("canGoDownFromJumpthru", true);
|
||||
}
|
||||
|
||||
@@ -108,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"))
|
||||
@@ -156,14 +157,23 @@ PlatformerObjectBehavior::GetProperties(
|
||||
.SetValue(gd::String::From(
|
||||
behaviorContent.GetDoubleAttribute("xGrabTolerance", 10)));
|
||||
properties["UseLegacyTrajectory"]
|
||||
.SetLabel(_("Use frame rate dependent trajectories (deprecated, it's "
|
||||
"recommended to leave this unchecked)"))
|
||||
.SetLabel(_("Use frame rate dependent trajectories "
|
||||
"(deprecated — best left unchecked)"))
|
||||
.SetGroup(_("Deprecated options"))
|
||||
.SetDeprecated()
|
||||
.SetValue(behaviorContent.GetBoolAttribute("useLegacyTrajectory", true)
|
||||
? "true"
|
||||
: "false")
|
||||
.SetType("Boolean");
|
||||
properties["UseRepeatedJump"]
|
||||
.SetLabel(_("Allows repeated jumps while holding the jump key "
|
||||
"(deprecated — best left unchecked)"))
|
||||
.SetGroup(_("Deprecated options"))
|
||||
.SetDeprecated()
|
||||
.SetValue(behaviorContent.GetBoolAttribute("useRepeatedJump", true)
|
||||
? "true"
|
||||
: "false")
|
||||
.SetType("Boolean");
|
||||
properties["CanGoDownFromJumpthru"]
|
||||
.SetLabel(_("Can go down from jumpthru platforms"))
|
||||
.SetQuickCustomizationVisibility(gd::QuickCustomization::Hidden)
|
||||
@@ -180,13 +190,15 @@ 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")
|
||||
behaviorContent.SetAttribute("canGrabWithoutMoving", (value == "1"));
|
||||
else if (name == "UseLegacyTrajectory")
|
||||
behaviorContent.SetAttribute("useLegacyTrajectory", (value == "1"));
|
||||
else if (name == "UseRepeatedJump")
|
||||
behaviorContent.SetAttribute("useRepeatedJump", (value == "1"));
|
||||
else if (name == "CanGoDownFromJumpthru")
|
||||
behaviorContent.SetAttribute("canGoDownFromJumpthru", (value == "1"));
|
||||
else if (name == "YGrabOffset")
|
||||
|
@@ -23,7 +23,6 @@ namespace gdjs {
|
||||
interface JumpingStateNetworkSyncData {
|
||||
cjs: number;
|
||||
tscjs: number;
|
||||
jkhsjs: boolean;
|
||||
jfd: boolean;
|
||||
}
|
||||
|
||||
@@ -57,6 +56,7 @@ namespace gdjs {
|
||||
juk: boolean;
|
||||
rpk: boolean;
|
||||
rlk: boolean;
|
||||
jkhsjs: boolean;
|
||||
sn: string;
|
||||
ssd: StateNetworkSyncData;
|
||||
}
|
||||
@@ -119,6 +119,7 @@ namespace gdjs {
|
||||
private _xGrabTolerance: any;
|
||||
|
||||
_useLegacyTrajectory: boolean;
|
||||
_useRepeatedJump: boolean;
|
||||
|
||||
_canGoDownFromJumpthru: boolean = false;
|
||||
|
||||
@@ -139,6 +140,7 @@ namespace gdjs {
|
||||
_upKey: boolean = false;
|
||||
_downKey: boolean = false;
|
||||
_jumpKey: boolean = false;
|
||||
_jumpKeyHeldSinceJumpStart: boolean = false;
|
||||
_releasePlatformKey: boolean = false;
|
||||
_releaseLadderKey: boolean = false;
|
||||
|
||||
@@ -204,6 +206,10 @@ namespace gdjs {
|
||||
behaviorData.useLegacyTrajectory === undefined
|
||||
? true
|
||||
: behaviorData.useLegacyTrajectory;
|
||||
this._useRepeatedJump =
|
||||
behaviorData.useRepeatedJump === undefined
|
||||
? true
|
||||
: behaviorData.useRepeatedJump;
|
||||
this._canGoDownFromJumpthru = behaviorData.canGoDownFromJumpthru;
|
||||
this._slopeMaxAngle = 0;
|
||||
this.setSlopeMaxAngle(behaviorData.slopeMaxAngle);
|
||||
@@ -249,6 +255,7 @@ namespace gdjs {
|
||||
juk: this._wasJumpKeyPressed,
|
||||
rpk: this._wasReleasePlatformKeyPressed,
|
||||
rlk: this._wasReleaseLadderKeyPressed,
|
||||
jkhsjs: this._jumpKeyHeldSinceJumpStart,
|
||||
sn: this._state.toString(),
|
||||
ssd: this._state.getNetworkSyncData(),
|
||||
},
|
||||
@@ -306,6 +313,9 @@ namespace gdjs {
|
||||
if (behaviorSpecificProps.rlk !== this._releaseLadderKey) {
|
||||
this._releaseLadderKey = behaviorSpecificProps.rlk;
|
||||
}
|
||||
if (behaviorSpecificProps.jkhsjs !== this._jumpKeyHeldSinceJumpStart) {
|
||||
this._jumpKeyHeldSinceJumpStart = behaviorSpecificProps.jkhsjs;
|
||||
}
|
||||
|
||||
if (behaviorSpecificProps.sn !== this._state.toString()) {
|
||||
switch (behaviorSpecificProps.sn) {
|
||||
@@ -427,6 +437,11 @@ namespace gdjs {
|
||||
(inputManager.isKeyPressed(LSHIFTKEY) ||
|
||||
inputManager.isKeyPressed(RSHIFTKEY) ||
|
||||
inputManager.isKeyPressed(SPACEKEY)));
|
||||
// Check if the jump key is continuously held since
|
||||
// the beginning of the jump.
|
||||
if (!this._jumpKey) {
|
||||
this._jumpKeyHeldSinceJumpStart = false;
|
||||
}
|
||||
|
||||
this._ladderKey ||
|
||||
(this._ladderKey =
|
||||
@@ -750,7 +765,11 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
_checkTransitionJumping() {
|
||||
if (this._canJump && this._jumpKey) {
|
||||
if (
|
||||
this._canJump &&
|
||||
this._jumpKey &&
|
||||
(!this._jumpKeyHeldSinceJumpStart || this._useRepeatedJump)
|
||||
) {
|
||||
this._setJumping();
|
||||
}
|
||||
}
|
||||
@@ -2270,7 +2289,6 @@ namespace gdjs {
|
||||
private _behavior: PlatformerObjectRuntimeBehavior;
|
||||
private _currentJumpSpeed: number = 0;
|
||||
private _timeSinceCurrentJumpStart: number = 0;
|
||||
private _jumpKeyHeldSinceJumpStart: boolean = false;
|
||||
private _jumpingFirstDelta: boolean = false;
|
||||
|
||||
constructor(behavior: PlatformerObjectRuntimeBehavior) {
|
||||
@@ -2288,7 +2306,7 @@ namespace gdjs {
|
||||
enter(from: State) {
|
||||
const behavior = this._behavior;
|
||||
this._timeSinceCurrentJumpStart = 0;
|
||||
this._jumpKeyHeldSinceJumpStart = true;
|
||||
behavior._jumpKeyHeldSinceJumpStart = true;
|
||||
|
||||
if (from !== behavior._jumping && from !== behavior._falling) {
|
||||
this._jumpingFirstDelta = true;
|
||||
@@ -2329,17 +2347,12 @@ namespace gdjs {
|
||||
beforeMovingY(timeDelta: float, oldX: float) {
|
||||
const behavior = this._behavior;
|
||||
|
||||
// Check if the jump key is continuously held since
|
||||
// the beginning of the jump.
|
||||
if (!behavior._jumpKey) {
|
||||
this._jumpKeyHeldSinceJumpStart = false;
|
||||
}
|
||||
this._timeSinceCurrentJumpStart += timeDelta;
|
||||
|
||||
const previousJumpSpeed = this._currentJumpSpeed;
|
||||
// Decrease jump speed after the (optional) jump sustain time is over.
|
||||
const sustainJumpSpeed =
|
||||
this._jumpKeyHeldSinceJumpStart &&
|
||||
behavior._jumpKeyHeldSinceJumpStart &&
|
||||
this._timeSinceCurrentJumpStart < behavior._jumpSustainTime;
|
||||
if (!sustainJumpSpeed) {
|
||||
this._currentJumpSpeed -= behavior._gravity * timeDelta;
|
||||
@@ -2374,7 +2387,6 @@ namespace gdjs {
|
||||
return {
|
||||
cjs: this._currentJumpSpeed,
|
||||
tscjs: this._timeSinceCurrentJumpStart,
|
||||
jkhsjs: this._jumpKeyHeldSinceJumpStart,
|
||||
jfd: this._jumpingFirstDelta,
|
||||
};
|
||||
}
|
||||
@@ -2382,7 +2394,6 @@ namespace gdjs {
|
||||
updateFromNetworkSyncData(data: JumpingStateNetworkSyncData) {
|
||||
this._currentJumpSpeed = data.cjs;
|
||||
this._timeSinceCurrentJumpStart = data.tscjs;
|
||||
this._jumpKeyHeldSinceJumpStart = data.jkhsjs;
|
||||
this._jumpingFirstDelta = data.jfd;
|
||||
}
|
||||
|
||||
|
@@ -27,6 +27,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: slopeMaxAngle,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -331,6 +332,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -491,6 +493,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -624,6 +627,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 0,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -728,6 +732,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
|
@@ -26,6 +26,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -172,6 +173,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -287,6 +289,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: true,
|
||||
useRepeatedJump: true,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -383,6 +386,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: true,
|
||||
useRepeatedJump: true,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -481,6 +485,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: true,
|
||||
useRepeatedJump: true,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -555,6 +560,8 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
let runtimeScene;
|
||||
let object;
|
||||
let platform;
|
||||
/** @type {gdjs.PlatformerObjectRuntimeBehavior} */
|
||||
let characterBehavior;
|
||||
|
||||
beforeEach(function () {
|
||||
runtimeScene = makePlatformerTestRuntimeScene();
|
||||
@@ -578,6 +585,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -585,6 +593,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
object.setCustomWidthAndHeight(10, 20);
|
||||
runtimeScene.addObject(object);
|
||||
object.setPosition(0, -32);
|
||||
characterBehavior = object.getBehavior('auto1');
|
||||
|
||||
// Put a platform.
|
||||
platform = addPlatformObject(runtimeScene);
|
||||
@@ -644,6 +653,48 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
expect(object.getY()).to.be(-30);
|
||||
});
|
||||
|
||||
it('can only jump once while the jump key is held', function () {
|
||||
// Ensure the object falls on the platform
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
}
|
||||
|
||||
//Check the object is on the platform
|
||||
expect(object.getY()).to.be(-30); // -30 = -10 (platform y) + -20 (object height)
|
||||
expect(characterBehavior.isFalling()).to.be(false);
|
||||
expect(characterBehavior.isFallingWithoutJumping()).to.be(false);
|
||||
expect(characterBehavior.isMoving()).to.be(false);
|
||||
|
||||
// The character jumps a first time.
|
||||
for (let i = 0; i < 80; ++i) {
|
||||
characterBehavior.simulateJumpKey();
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
characterBehavior.isJumping(true);
|
||||
}
|
||||
// The character lands back on the floor
|
||||
// while the player holds the jump key.
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
characterBehavior.simulateJumpKey();
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
}
|
||||
characterBehavior.isOnFloor(true);
|
||||
expect(object.getY()).to.be(-30);
|
||||
|
||||
// The character doesn't jump a 2nd time.
|
||||
characterBehavior.simulateJumpKey();
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
characterBehavior.isOnFloor(true);
|
||||
|
||||
// The player release the jump key.
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
characterBehavior.isOnFloor(true);
|
||||
|
||||
// The character can now jump again.
|
||||
characterBehavior.simulateJumpKey();
|
||||
runtimeScene.renderAndStep(1000 / 60);
|
||||
characterBehavior.isJumping(true);
|
||||
});
|
||||
|
||||
it('can jump, and only sustain the jump while key held', function () {
|
||||
// Ensure the object falls on the platform
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
@@ -1126,6 +1177,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
canGoDownFromJumpthru: true,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -1467,6 +1519,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -1497,6 +1550,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
{
|
||||
type: 'PlatformBehavior::PlatformBehavior',
|
||||
@@ -1637,6 +1691,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -1758,6 +1813,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -1843,6 +1899,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -1921,6 +1978,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
|
@@ -26,6 +26,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
canGrabWithoutMoving: canGrabWithoutMoving,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -233,6 +234,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
canGrabWithoutMoving: true,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -398,6 +400,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
|
@@ -32,6 +32,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -308,6 +309,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
|
@@ -27,6 +27,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -438,6 +439,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: 60,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
@@ -546,6 +548,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () {
|
||||
slopeMaxAngle: slopeMaxAngle,
|
||||
jumpSustainTime: 0.2,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
|
@@ -36,6 +36,7 @@ describe(`gdjs.PlatformerObjectRuntimeBehavior.findHighestFloorAndMoveOnTop`, fu
|
||||
ignoreDefaultControls: true,
|
||||
slopeMaxAngle: 60,
|
||||
useLegacyTrajectory: false,
|
||||
useRepeatedJump: false,
|
||||
},
|
||||
],
|
||||
effects: [],
|
||||
|
@@ -311,7 +311,7 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) {
|
||||
"such as \"Path line\" (in the Advanced category) can be "
|
||||
"used to draw. Be sure to use \"End fill path\" action when "
|
||||
"you're done drawing the shape."),
|
||||
_("Begins drawing filling of an advanced path "
|
||||
_("Begin drawing filling of an advanced path "
|
||||
"with _PARAM0_ (start: _PARAM1_;_PARAM2_)"),
|
||||
_("Advanced"),
|
||||
"res/actions/beginFillPath24.png",
|
||||
|
@@ -34,6 +34,7 @@ void TopDownMovementBehavior::InitializeContent(
|
||||
behaviorContent.SetAttribute("viewpoint", "TopDown");
|
||||
behaviorContent.SetAttribute("customIsometryAngle", 30);
|
||||
behaviorContent.SetAttribute("movementAngleOffset", 0);
|
||||
behaviorContent.SetAttribute("useLegacyTurnBack", false);
|
||||
}
|
||||
|
||||
std::map<gd::String, gd::PropertyDescriptor>
|
||||
@@ -94,6 +95,15 @@ TopDownMovementBehavior::GetProperties(
|
||||
? "false"
|
||||
: "true")
|
||||
.SetType("Boolean");
|
||||
properties["UseLegacyTurnBack"]
|
||||
.SetLabel(_("Only use acceleration to turn back "
|
||||
"(deprecated — best left unchecked)"))
|
||||
.SetGroup(_("Deprecated options"))
|
||||
.SetDeprecated()
|
||||
.SetValue(behaviorContent.GetBoolAttribute("useLegacyTurnBack", true)
|
||||
? "true"
|
||||
: "false")
|
||||
.SetType("Boolean");
|
||||
|
||||
gd::String viewpoint = behaviorContent.GetStringAttribute("viewpoint");
|
||||
gd::String viewpointStr = _("Top-Down");
|
||||
@@ -156,6 +166,9 @@ bool TopDownMovementBehavior::UpdateProperty(
|
||||
behaviorContent.SetAttribute("rotateObject", (value != "0"));
|
||||
return true;
|
||||
}
|
||||
if (name == "UseLegacyTurnBack") {
|
||||
behaviorContent.SetAttribute("useLegacyTurnBack", (value == "1"));
|
||||
}
|
||||
if (name == "Viewpoint") {
|
||||
// Fix the offset angle when switching between top-down and isometry
|
||||
const gd::String& oldValue =
|
||||
|
@@ -37,6 +37,7 @@ namespace gdjs {
|
||||
private _angleOffset: float;
|
||||
private _ignoreDefaultControls: boolean;
|
||||
private _movementAngleOffset: float;
|
||||
private _useLegacyTurnBack: boolean;
|
||||
|
||||
/** The latest angle of movement, in degrees. */
|
||||
private _angle: float = 0;
|
||||
@@ -102,6 +103,10 @@ namespace gdjs {
|
||||
behaviorData.customIsometryAngle
|
||||
);
|
||||
this._movementAngleOffset = behaviorData.movementAngleOffset || 0;
|
||||
this._useLegacyTurnBack =
|
||||
behaviorData.useLegacyTurnBack === undefined
|
||||
? true
|
||||
: behaviorData.useLegacyTurnBack;
|
||||
}
|
||||
|
||||
getNetworkSyncData(): TopDownMovementNetworkSyncData {
|
||||
@@ -303,9 +308,7 @@ namespace gdjs {
|
||||
}
|
||||
|
||||
getSpeed(): float {
|
||||
return Math.sqrt(
|
||||
this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity
|
||||
);
|
||||
return Math.hypot(this._xVelocity, this._yVelocity);
|
||||
}
|
||||
|
||||
getXVelocity(): float {
|
||||
@@ -468,74 +471,71 @@ namespace gdjs {
|
||||
// variables without assigning them a value.
|
||||
let directionInRad = 0;
|
||||
let directionInDeg = 0;
|
||||
let cos = 1;
|
||||
let sin = 0;
|
||||
|
||||
let isMoving = false;
|
||||
let targetedSpeed = 0;
|
||||
// Update the speed of the object:
|
||||
if (direction !== -1) {
|
||||
isMoving = true;
|
||||
directionInRad =
|
||||
((direction + this._movementAngleOffset / 45) * Math.PI) / 4.0;
|
||||
directionInDeg = direction * 45 + this._movementAngleOffset;
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity += this._acceleration * timeDelta * cos;
|
||||
this._yVelocity += this._acceleration * timeDelta * sin;
|
||||
targetedSpeed = this._maxSpeed;
|
||||
} else if (this._stickForce !== 0) {
|
||||
isMoving = true;
|
||||
if (!this._allowDiagonals) {
|
||||
this._stickAngle = 90 * Math.floor((this._stickAngle + 45) / 90);
|
||||
}
|
||||
directionInDeg = this._stickAngle + this._movementAngleOffset;
|
||||
directionInRad = (directionInDeg * Math.PI) / 180;
|
||||
const norm = this._acceleration * timeDelta * this._stickForce;
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity += norm * cos;
|
||||
this._yVelocity += norm * sin;
|
||||
targetedSpeed = this._maxSpeed * this._stickForce;
|
||||
|
||||
this._wasStickUsed = true;
|
||||
this._stickForce = 0;
|
||||
} else if (this._yVelocity !== 0 || this._xVelocity !== 0) {
|
||||
isMoving = true;
|
||||
directionInRad = Math.atan2(this._yVelocity, this._xVelocity);
|
||||
directionInDeg = (directionInRad * 180.0) / Math.PI;
|
||||
const xVelocityWasPositive = this._xVelocity >= 0;
|
||||
const yVelocityWasPositive = this._yVelocity >= 0;
|
||||
}
|
||||
if (isMoving) {
|
||||
// This makes the trigo resilient to rounding errors on directionInRad.
|
||||
cos = Math.cos(directionInRad);
|
||||
sin = Math.sin(directionInRad);
|
||||
let cos = Math.cos(directionInRad);
|
||||
let sin = Math.sin(directionInRad);
|
||||
if (cos === -1 || cos === 1) {
|
||||
sin = 0;
|
||||
}
|
||||
if (sin === -1 || sin === 1) {
|
||||
cos = 0;
|
||||
}
|
||||
this._xVelocity -= this._deceleration * timeDelta * cos;
|
||||
this._yVelocity -= this._deceleration * timeDelta * sin;
|
||||
if (this._xVelocity > 0 !== xVelocityWasPositive) {
|
||||
this._xVelocity = 0;
|
||||
}
|
||||
if (this._yVelocity > 0 !== yVelocityWasPositive) {
|
||||
this._yVelocity = 0;
|
||||
|
||||
const getAcceleratedSpeed = this._useLegacyTurnBack
|
||||
? TopDownMovementRuntimeBehavior.getLegacyAcceleratedSpeed
|
||||
: TopDownMovementRuntimeBehavior.getAcceleratedSpeed;
|
||||
|
||||
let currentSpeed = Math.hypot(this._xVelocity, this._yVelocity);
|
||||
const dotProduct = this._xVelocity * cos + this._yVelocity * sin;
|
||||
if (dotProduct < 0) {
|
||||
// The object is turning back.
|
||||
// Keep the negative velocity projected on the new direction.
|
||||
currentSpeed = dotProduct;
|
||||
}
|
||||
const speed = getAcceleratedSpeed(
|
||||
currentSpeed,
|
||||
targetedSpeed,
|
||||
this._maxSpeed,
|
||||
this._acceleration,
|
||||
this._deceleration,
|
||||
timeDelta
|
||||
);
|
||||
this._xVelocity = speed * cos;
|
||||
this._yVelocity = speed * sin;
|
||||
}
|
||||
|
||||
const squaredSpeed =
|
||||
this._xVelocity * this._xVelocity + this._yVelocity * this._yVelocity;
|
||||
if (squaredSpeed > this._maxSpeed * this._maxSpeed) {
|
||||
this._xVelocity = this._maxSpeed * cos;
|
||||
this._yVelocity = this._maxSpeed * sin;
|
||||
const ratio = this._maxSpeed / Math.sqrt(squaredSpeed);
|
||||
this._xVelocity *= ratio;
|
||||
this._yVelocity *= ratio;
|
||||
}
|
||||
|
||||
// No acceleration for angular speed for now.
|
||||
@@ -589,9 +589,125 @@ namespace gdjs {
|
||||
this._rightKey = false;
|
||||
this._upKey = false;
|
||||
this._downKey = false;
|
||||
this._stickForce = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static getAcceleratedSpeed(
|
||||
currentSpeed: float,
|
||||
targetedSpeed: float,
|
||||
speedMax: float,
|
||||
acceleration: float,
|
||||
deceleration: float,
|
||||
timeDelta: float
|
||||
): float {
|
||||
let newSpeed = currentSpeed;
|
||||
const turningBackAcceleration = Math.max(acceleration, deceleration);
|
||||
if (targetedSpeed < 0) {
|
||||
if (currentSpeed <= targetedSpeed) {
|
||||
// Reduce the speed to match the stick force.
|
||||
newSpeed = Math.min(
|
||||
targetedSpeed,
|
||||
currentSpeed + turningBackAcceleration * timeDelta
|
||||
);
|
||||
} else if (currentSpeed <= 0) {
|
||||
// Accelerate
|
||||
newSpeed -= Math.max(-speedMax, acceleration * timeDelta);
|
||||
} else {
|
||||
// Turn back at least as fast as it would stop.
|
||||
newSpeed = Math.max(
|
||||
targetedSpeed,
|
||||
currentSpeed - turningBackAcceleration * timeDelta
|
||||
);
|
||||
}
|
||||
} else if (targetedSpeed > 0) {
|
||||
if (currentSpeed >= targetedSpeed) {
|
||||
// Reduce the speed to match the stick force.
|
||||
newSpeed = Math.max(
|
||||
targetedSpeed,
|
||||
currentSpeed - turningBackAcceleration * timeDelta
|
||||
);
|
||||
} else if (currentSpeed >= 0) {
|
||||
// Accelerate
|
||||
newSpeed = Math.min(
|
||||
speedMax,
|
||||
currentSpeed + acceleration * timeDelta
|
||||
);
|
||||
} else {
|
||||
// Turn back at least as fast as it would stop.
|
||||
newSpeed = Math.min(
|
||||
targetedSpeed,
|
||||
currentSpeed + turningBackAcceleration * timeDelta
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Decelerate and stop.
|
||||
if (currentSpeed < 0) {
|
||||
newSpeed = Math.min(currentSpeed + deceleration * timeDelta, 0);
|
||||
}
|
||||
if (currentSpeed > 0) {
|
||||
newSpeed = Math.max(currentSpeed - deceleration * timeDelta, 0);
|
||||
}
|
||||
}
|
||||
return newSpeed;
|
||||
}
|
||||
|
||||
private static getLegacyAcceleratedSpeed(
|
||||
currentSpeed: float,
|
||||
targetedSpeed: float,
|
||||
speedMax: float,
|
||||
acceleration: float,
|
||||
deceleration: float,
|
||||
timeDelta: float
|
||||
): float {
|
||||
let newSpeed = currentSpeed;
|
||||
if (targetedSpeed < 0) {
|
||||
if (currentSpeed <= targetedSpeed) {
|
||||
// Reduce the speed to match the stick force.
|
||||
newSpeed = Math.min(
|
||||
targetedSpeed,
|
||||
currentSpeed + deceleration * timeDelta
|
||||
);
|
||||
} else if (currentSpeed <= 0) {
|
||||
// Accelerate
|
||||
newSpeed -= Math.max(-speedMax, acceleration * timeDelta);
|
||||
} else {
|
||||
newSpeed = Math.max(
|
||||
targetedSpeed,
|
||||
currentSpeed - deceleration * timeDelta
|
||||
);
|
||||
}
|
||||
} else if (targetedSpeed > 0) {
|
||||
if (currentSpeed >= targetedSpeed) {
|
||||
// Reduce the speed to match the stick force.
|
||||
newSpeed = Math.max(
|
||||
targetedSpeed,
|
||||
currentSpeed - deceleration * timeDelta
|
||||
);
|
||||
} else if (currentSpeed >= 0) {
|
||||
// Accelerate
|
||||
newSpeed = Math.min(
|
||||
speedMax,
|
||||
currentSpeed + acceleration * timeDelta
|
||||
);
|
||||
} else {
|
||||
newSpeed = Math.min(
|
||||
targetedSpeed,
|
||||
currentSpeed + deceleration * timeDelta
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Decelerate and stop.
|
||||
if (currentSpeed < 0) {
|
||||
newSpeed = Math.min(currentSpeed + deceleration * timeDelta, 0);
|
||||
}
|
||||
if (currentSpeed > 0) {
|
||||
newSpeed = Math.max(currentSpeed - deceleration * timeDelta, 0);
|
||||
}
|
||||
}
|
||||
return newSpeed;
|
||||
}
|
||||
|
||||
simulateControl(input: string) {
|
||||
if (input === 'Left') {
|
||||
this._leftKey = true;
|
||||
|
@@ -60,7 +60,7 @@ module.exports = {
|
||||
'Tween',
|
||||
_('Tweening'),
|
||||
_(
|
||||
'Animate object properties over time. This allows smooth transitions, animations or movement of objects to specified positions.'
|
||||
'Smoothly animate object properties over time — such as position, rotation scale, opacity, and more — as well as variables. Ideal for creating fluid transitions and UI animations. While you can use tweens to move objects, other behaviors (like platform, physics, ellipse movement...) or forces are often better suited for dynamic movement. Tween is best used for animating UI elements, static objects that need to move from one point to another, or other values like variables.'
|
||||
),
|
||||
'Matthias Meike, Florian Rival',
|
||||
'Open source (MIT License)'
|
||||
|
@@ -397,60 +397,68 @@ namespace gdjs {
|
||||
document.addEventListener(
|
||||
'pause',
|
||||
function () {
|
||||
const soundList = that._freeSounds.concat(that._freeMusics);
|
||||
for (let key in that._sounds) {
|
||||
if (that._sounds.hasOwnProperty(key)) {
|
||||
soundList.push(that._sounds[key]);
|
||||
}
|
||||
}
|
||||
for (let key in that._musics) {
|
||||
if (that._musics.hasOwnProperty(key)) {
|
||||
soundList.push(that._musics[key]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < soundList.length; i++) {
|
||||
const sound = soundList[i];
|
||||
if (!sound.paused() && !sound.stopped()) {
|
||||
sound.pause();
|
||||
that._pausedSounds.push(sound);
|
||||
}
|
||||
}
|
||||
that._paused = true;
|
||||
that.pauseAllActiveSounds();
|
||||
},
|
||||
false
|
||||
);
|
||||
document.addEventListener(
|
||||
'resume',
|
||||
function () {
|
||||
try {
|
||||
for (let i = 0; i < that._pausedSounds.length; i++) {
|
||||
const sound = that._pausedSounds[i];
|
||||
if (!sound.stopped()) {
|
||||
sound.play();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message &&
|
||||
typeof error.message === 'string' &&
|
||||
error.message.startsWith('Maximum call stack size exceeded')
|
||||
) {
|
||||
console.warn(
|
||||
'An error occurred when resuming paused sounds while the game was in background:',
|
||||
error
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
that._pausedSounds.length = 0;
|
||||
that._paused = false;
|
||||
that.resumeAllActiveSounds();
|
||||
},
|
||||
false
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pauseAllActiveSounds(): void {
|
||||
const soundList = this._freeSounds.concat(this._freeMusics);
|
||||
for (let key in this._sounds) {
|
||||
if (this._sounds.hasOwnProperty(key)) {
|
||||
soundList.push(this._sounds[key]);
|
||||
}
|
||||
}
|
||||
for (let key in this._musics) {
|
||||
if (this._musics.hasOwnProperty(key)) {
|
||||
soundList.push(this._musics[key]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < soundList.length; i++) {
|
||||
const sound = soundList[i];
|
||||
if (!sound.paused() && !sound.stopped()) {
|
||||
sound.pause();
|
||||
this._pausedSounds.push(sound);
|
||||
}
|
||||
}
|
||||
this._paused = true;
|
||||
}
|
||||
|
||||
resumeAllActiveSounds(): void {
|
||||
try {
|
||||
for (let i = 0; i < this._pausedSounds.length; i++) {
|
||||
const sound = this._pausedSounds[i];
|
||||
if (!sound.stopped()) {
|
||||
sound.play();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error.message &&
|
||||
typeof error.message === 'string' &&
|
||||
error.message.startsWith('Maximum call stack size exceeded')
|
||||
) {
|
||||
console.warn(
|
||||
'An error occurred when resuming paused sounds while the game was in background:',
|
||||
error
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this._pausedSounds.length = 0;
|
||||
this._paused = false;
|
||||
}
|
||||
|
||||
getResourceKinds(): ResourceKind[] {
|
||||
return resourceKinds;
|
||||
}
|
||||
|
@@ -2293,6 +2293,8 @@ interface PlatformExtension {
|
||||
[Const, Value] DOMString STATIC_GetNamespaceSeparator();
|
||||
[Const, Value] DOMString STATIC_GetBehaviorFullType(
|
||||
[Const] DOMString extensionName, [Const] DOMString behaviorName);
|
||||
[Const, Value] DOMString STATIC_GetExtensionFromFullBehaviorType([Const] DOMString type);
|
||||
[Const, Value] DOMString STATIC_GetBehaviorNameFromFullBehaviorType([Const] DOMString type);
|
||||
[Const, Value] DOMString STATIC_GetObjectFullType(
|
||||
[Const] DOMString extensionName, [Const] DOMString objectName);
|
||||
[Const, Value] DOMString STATIC_GetExtensionFromFullObjectType([Const] DOMString type);
|
||||
@@ -2342,6 +2344,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 {
|
||||
@@ -2805,6 +2810,7 @@ interface WholeProjectRefactorer {
|
||||
unsigned long STATIC_GetLayoutAndExternalLayoutLayerInstancesCount([Ref] Project project, [Ref] Layout layout, [Const] DOMString layerName);
|
||||
void STATIC_RenameLeaderboards([Ref] Project project, [Const, Ref] MapStringString leaderboardIdMap);
|
||||
[Value] SetString STATIC_FindAllLeaderboardIds([Ref] Project project);
|
||||
void STATIC_UpdateBehaviorsSharedData([Ref] Project project);
|
||||
};
|
||||
|
||||
interface ObjectTools {
|
||||
|
@@ -674,6 +674,7 @@ typedef ExtensionAndMetadata<ExpressionMetadata> ExtensionAndExpressionMetadata;
|
||||
GetLayoutAndExternalLayoutLayerInstancesCount
|
||||
#define STATIC_RenameLeaderboards RenameLeaderboards
|
||||
#define STATIC_FindAllLeaderboardIds FindAllLeaderboardIds
|
||||
#define STATIC_UpdateBehaviorsSharedData UpdateBehaviorsSharedData
|
||||
|
||||
#define STATIC_GenerateBehaviorGetterAndSetter GenerateBehaviorGetterAndSetter
|
||||
#define STATIC_GenerateObjectGetterAndSetter GenerateObjectGetterAndSetter
|
||||
@@ -715,6 +716,8 @@ typedef ExtensionAndMetadata<ExpressionMetadata> ExtensionAndExpressionMetadata;
|
||||
|
||||
#define STATIC_GetNamespaceSeparator GetNamespaceSeparator
|
||||
#define STATIC_GetBehaviorFullType GetBehaviorFullType
|
||||
#define STATIC_GetExtensionFromFullBehaviorType GetExtensionFromFullBehaviorType
|
||||
#define STATIC_GetBehaviorNameFromFullBehaviorType GetBehaviorNameFromFullBehaviorType
|
||||
#define STATIC_GetObjectFullType GetObjectFullType
|
||||
#define STATIC_GetExtensionFromFullObjectType GetExtensionFromFullObjectType
|
||||
#define STATIC_GetObjectNameFromFullObjectType GetObjectNameFromFullObjectType
|
||||
|
@@ -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 () {
|
||||
|
5
GDevelop.js/types.d.ts
vendored
5
GDevelop.js/types.d.ts
vendored
@@ -1740,6 +1740,8 @@ export class PlatformExtension extends EmscriptenObject {
|
||||
getAllSourceFiles(): VectorSourceFileMetadata;
|
||||
static getNamespaceSeparator(): string;
|
||||
static getBehaviorFullType(extensionName: string, behaviorName: string): string;
|
||||
static getExtensionFromFullBehaviorType(type: string): string;
|
||||
static getBehaviorNameFromFullBehaviorType(type: string): string;
|
||||
static getObjectFullType(extensionName: string, objectName: string): string;
|
||||
static getExtensionFromFullObjectType(type: string): string;
|
||||
static getObjectNameFromFullObjectType(type: string): string;
|
||||
@@ -1780,6 +1782,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 {
|
||||
@@ -1994,6 +1998,7 @@ export class WholeProjectRefactorer extends EmscriptenObject {
|
||||
static getLayoutAndExternalLayoutLayerInstancesCount(project: Project, layout: Layout, layerName: string): number;
|
||||
static renameLeaderboards(project: Project, leaderboardIdMap: MapStringString): void;
|
||||
static findAllLeaderboardIds(project: Project): SetString;
|
||||
static updateBehaviorsSharedData(project: Project): void;
|
||||
}
|
||||
|
||||
export class ObjectTools extends EmscriptenObject {
|
||||
|
@@ -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;
|
||||
};
|
@@ -57,6 +57,8 @@ declare class gdPlatformExtension {
|
||||
getAllSourceFiles(): gdVectorSourceFileMetadata;
|
||||
static getNamespaceSeparator(): string;
|
||||
static getBehaviorFullType(extensionName: string, behaviorName: string): string;
|
||||
static getExtensionFromFullBehaviorType(type: string): string;
|
||||
static getBehaviorNameFromFullBehaviorType(type: string): string;
|
||||
static getObjectFullType(extensionName: string, objectName: string): string;
|
||||
static getExtensionFromFullObjectType(type: string): string;
|
||||
static getObjectNameFromFullObjectType(type: string): string;
|
||||
|
@@ -63,6 +63,7 @@ declare class gdWholeProjectRefactorer {
|
||||
static getLayoutAndExternalLayoutLayerInstancesCount(project: gdProject, layout: gdLayout, layerName: string): number;
|
||||
static renameLeaderboards(project: gdProject, leaderboardIdMap: gdMapStringString): void;
|
||||
static findAllLeaderboardIds(project: gdProject): gdSetString;
|
||||
static updateBehaviorsSharedData(project: gdProject): void;
|
||||
delete(): void;
|
||||
ptr: number;
|
||||
};
|
21
appveyor.yml
21
appveyor.yml
@@ -1,6 +1,8 @@
|
||||
# AppVeyor configuration to build GDevelop app running
|
||||
# Deprecated AppVeyor configuration to build GDevelop app running
|
||||
# on the Electron runtime (newIDE/electron-app) for Windows.
|
||||
# For macOS and Linux, see the config.yml file.
|
||||
#
|
||||
# This was replaced by build on CircleCI - but kept for redundancy/tests.
|
||||
# For Windows, macOS and Linux builds, see the config.yml file.
|
||||
|
||||
version: 1.0.{build}
|
||||
pull_requests:
|
||||
@@ -17,6 +19,7 @@ cache:
|
||||
- newIDE\app\node_modules -> newIDE\app\package-lock.json
|
||||
- newIDE\electron-app\node_modules -> newIDE\electron-app\package-lock.json
|
||||
- GDevelop.js\node_modules -> GDevelop.js\package-lock.json
|
||||
- GDJS\node_modules -> GDJS\package-lock.json
|
||||
install:
|
||||
# Download and install SSL.com eSigner CKA.
|
||||
# See https://www.ssl.com/how-to/how-to-integrate-esigner-cka-with-ci-cd-tools-for-automated-code-signing/.
|
||||
@@ -90,9 +93,8 @@ build_script:
|
||||
echo Certificate: $CodeSigningCert
|
||||
|
||||
# Use a custom signtool path because of the signtool.exe bundled withy electron-builder not working for some reason.
|
||||
# Can also be found in versioned folders like "C:/Program Files (x86)/Windows Kits/10/bin/10.0.22000.0/x86/signtool.exe".
|
||||
|
||||
$Env:SIGNTOOL_PATH = "C:\Program Files (x86)\Windows Kits\10\App Certification Kit\signtool.exe"
|
||||
$Env:SIGNTOOL_PATH = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.22000.0\x86\signtool.exe"
|
||||
|
||||
# Extract thumbprint and subject name of the certificate (will be passed to electron-builder).
|
||||
|
||||
@@ -132,8 +134,9 @@ artifacts:
|
||||
name: GDevelopWindows
|
||||
|
||||
# Upload artifacts (AWS) - configuration is stored on AppVeyor itself.
|
||||
deploy:
|
||||
- provider: Environment
|
||||
name: Amazon S3 releases
|
||||
- provider: Environment
|
||||
name: Amazon S3 latest releases
|
||||
# Disabled because done by CircleCI "build-windows" job.
|
||||
# deploy:
|
||||
# - provider: Environment
|
||||
# name: Amazon S3 releases
|
||||
# - provider: Environment
|
||||
# name: Amazon S3 latest releases
|
||||
|
@@ -18,5 +18,6 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
'@storybook/preset-create-react-app',
|
||||
'storybook-addon-mock',
|
||||
],
|
||||
};
|
||||
|
3412
newIDE/app/package-lock.json
generated
3412
newIDE/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,13 @@
|
||||
"@babel/preset-react": "^7.22.5",
|
||||
"@lingui/cli": "^2.7.3",
|
||||
"@lingui/macro": "^2.7.3",
|
||||
"@storybook/addon-essentials": "^7.4.0",
|
||||
"@storybook/addons": "^7.4.0",
|
||||
"@storybook/components": "^7.4.0",
|
||||
"@storybook/preset-create-react-app": "^7.4.0",
|
||||
"@storybook/react": "^7.4.0",
|
||||
"@storybook/react-webpack5": "^7.4.0",
|
||||
"@storybook/theming": "^7.4.0",
|
||||
"@storybook/addon-essentials": "7.4.6",
|
||||
"@storybook/addons": "7.4.6",
|
||||
"@storybook/components": "7.4.6",
|
||||
"@storybook/preset-create-react-app": "7.4.6",
|
||||
"@storybook/react": "7.4.6",
|
||||
"@storybook/react-webpack5": "7.4.6",
|
||||
"@storybook/theming": "7.4.6",
|
||||
"adm-zip": "^0.5.10",
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
@@ -33,7 +33,8 @@
|
||||
"recursive-copy": "^2.0.14",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
"shelljs": "0.8.4",
|
||||
"storybook": "^7.4.0",
|
||||
"storybook": "7.4.6",
|
||||
"storybook-addon-mock": "4.3.0",
|
||||
"style-dictionary": "^2.10.2",
|
||||
"typescript": "^4.1.3",
|
||||
"webpack": "5.88.2",
|
||||
|
@@ -158,13 +158,18 @@ ${extension.getDescription()} ${generateReadMoreLink(extension.getHelpPath())}
|
||||
};
|
||||
};
|
||||
|
||||
/** @returns {String} */
|
||||
const generateBuiltInExtensionNote = ({ extension }) => {
|
||||
return `The ${extension.getFullName()} extension is always installed in all GDevelop projects: there is no need to add it from the Project Manager.\n\n`;
|
||||
};
|
||||
|
||||
/** @returns {RawText} */
|
||||
const generateExtensionFooterText = ({ extension }) => {
|
||||
return {
|
||||
text:
|
||||
`
|
||||
---
|
||||
*This page is an auto-generated reference page about the **${extension.getFullName()}** feature of [GDevelop, the open-source, cross-platform game engine designed for everyone](https://gdevelop.io/).*` +
|
||||
`\n\n---\n\n` +
|
||||
generateBuiltInExtensionNote({ extension }) +
|
||||
`*This page is an auto-generated reference page about the **${extension.getFullName()}** feature of [GDevelop, the open-source, cross-platform game engine designed for everyone](https://gdevelop.io/).*` +
|
||||
' ' +
|
||||
'Learn more about [all GDevelop features here](/gdevelop5/all-features).',
|
||||
};
|
||||
|
@@ -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;
|
||||
@@ -56,18 +32,6 @@
|
||||
animation: new-chat-appear 0.5s;
|
||||
}
|
||||
|
||||
.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 */
|
||||
}
|
||||
|
||||
.thinkingText {
|
||||
animation: thinking-appear 1s;
|
||||
}
|
158
newIDE/app/src/AiGeneration/AiRequestChat/AiRequestUtils.js
Normal file
158
newIDE/app/src/AiGeneration/AiRequestChat/AiRequestUtils.js
Normal file
@@ -0,0 +1,158 @@
|
||||
// @flow
|
||||
import {
|
||||
type AiRequest,
|
||||
type AiRequestMessageAssistantFunctionCall,
|
||||
type AiRequestFunctionCallOutput,
|
||||
} from '../../Utils/GDevelopServices/Generation';
|
||||
import { type EditorFunctionCallResult } from '../../EditorFunctions/EditorFunctionCallRunner';
|
||||
|
||||
export const getFunctionCallToFunctionCallOutputMap = ({
|
||||
aiRequest,
|
||||
}: {|
|
||||
aiRequest: AiRequest,
|
||||
|}): Map<
|
||||
AiRequestMessageAssistantFunctionCall,
|
||||
AiRequestFunctionCallOutput | null
|
||||
> => {
|
||||
// Maps each function call to its corresponding output (or null if no output)
|
||||
const functionCallsToOutputs = new Map<
|
||||
AiRequestMessageAssistantFunctionCall,
|
||||
AiRequestFunctionCallOutput | null
|
||||
>();
|
||||
|
||||
// Track function calls by their call_id to match with outputs
|
||||
const functionCallsByCallId = new Map<
|
||||
string,
|
||||
AiRequestMessageAssistantFunctionCall
|
||||
>();
|
||||
|
||||
// Process messages in a single loop
|
||||
for (let i = 0; i < aiRequest.output.length; i++) {
|
||||
const message = aiRequest.output[i];
|
||||
|
||||
if (message.type === 'message' && message.role === 'assistant') {
|
||||
// Process function calls in this message
|
||||
message.content.forEach(content => {
|
||||
if (content.type === 'function_call') {
|
||||
// Initialize with null output - will be updated if we find a matching output
|
||||
functionCallsToOutputs.set(content, null);
|
||||
|
||||
// Store function call by call_id for later matching
|
||||
functionCallsByCallId.set(content.call_id, content);
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'function_call_output') {
|
||||
// Find the corresponding function calls with this call_id
|
||||
const functionCall = functionCallsByCallId.get(message.call_id);
|
||||
functionCallsByCallId.delete(message.call_id);
|
||||
|
||||
// Match with the most recent function call with this call_id
|
||||
if (functionCall) {
|
||||
functionCallsToOutputs.set(functionCall, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functionCallsToOutputs;
|
||||
};
|
||||
|
||||
export const getFunctionCallsToProcess = ({
|
||||
aiRequest,
|
||||
editorFunctionCallResults,
|
||||
}: {|
|
||||
aiRequest: AiRequest,
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null,
|
||||
|}): Array<AiRequestMessageAssistantFunctionCall> => {
|
||||
const functionCallsToProcess: AiRequestMessageAssistantFunctionCall[] = [];
|
||||
const appliedFunctionCallIds = new Set<string>();
|
||||
const alreadyProcessedFunctionCallIds = new Set<string>();
|
||||
|
||||
// Track already applied function calls
|
||||
(editorFunctionCallResults || []).forEach(functionCallOutput => {
|
||||
appliedFunctionCallIds.add(functionCallOutput.call_id);
|
||||
});
|
||||
|
||||
// Process from the end and collect function calls until we hit a message with no function calls
|
||||
let foundFunctionCall = false;
|
||||
|
||||
for (let i = aiRequest.output.length - 1; i >= 0; i--) {
|
||||
const message = aiRequest.output[i];
|
||||
|
||||
// Track already processed function call outputs
|
||||
if (message.type === 'function_call_output') {
|
||||
alreadyProcessedFunctionCallIds.add(message.call_id);
|
||||
}
|
||||
|
||||
// Collect function calls that need processing
|
||||
if (message.type === 'message' && message.role === 'assistant') {
|
||||
const functionCalls = message.content.filter(
|
||||
content => content.type === 'function_call'
|
||||
);
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
foundFunctionCall = true;
|
||||
|
||||
// Add new unique function calls that haven't been processed or applied
|
||||
for (let j = functionCalls.length - 1; j >= 0; j--) {
|
||||
const functionCall = functionCalls[j];
|
||||
if (functionCall.type !== 'function_call') continue;
|
||||
|
||||
if (
|
||||
!alreadyProcessedFunctionCallIds.has(functionCall.call_id) &&
|
||||
!appliedFunctionCallIds.has(functionCall.call_id)
|
||||
) {
|
||||
functionCallsToProcess.unshift(functionCall); // Add to beginning to preserve original order
|
||||
}
|
||||
}
|
||||
} else if (foundFunctionCall) {
|
||||
// If we've found function calls and now hit a message with no function calls, stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functionCallsToProcess;
|
||||
};
|
||||
|
||||
export const getFunctionCallOutputsFromEditorFunctionCallResults = (
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null
|
||||
): {|
|
||||
hasUnfinishedResult: boolean,
|
||||
functionCallOutputs: Array<AiRequestFunctionCallOutput>,
|
||||
|} => {
|
||||
if (!editorFunctionCallResults)
|
||||
return { hasUnfinishedResult: false, functionCallOutputs: [] };
|
||||
|
||||
let hasUnfinishedResult = false;
|
||||
const functionCallOutputs = editorFunctionCallResults
|
||||
.map(functionCallOutput => {
|
||||
if (functionCallOutput.status === 'finished') {
|
||||
return {
|
||||
type: 'function_call_output',
|
||||
call_id: functionCallOutput.call_id,
|
||||
output: JSON.stringify({
|
||||
success: functionCallOutput.success,
|
||||
...functionCallOutput.output,
|
||||
}),
|
||||
};
|
||||
} else if (functionCallOutput.status === 'ignored') {
|
||||
return {
|
||||
type: 'function_call_output',
|
||||
call_id: functionCallOutput.call_id,
|
||||
output: JSON.stringify({
|
||||
ignored: true,
|
||||
message: 'This was marked as ignored by the user.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
hasUnfinishedResult = true;
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
functionCallOutputs,
|
||||
hasUnfinishedResult,
|
||||
};
|
||||
};
|
37
newIDE/app/src/AiGeneration/AiRequestChat/ChatBubble.js
Normal file
37
newIDE/app/src/AiGeneration/AiRequestChat/ChatBubble.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import classes from './ChatBubble.module.css';
|
||||
import Paper from '../../UI/Paper';
|
||||
|
||||
const styles = {
|
||||
chatBubble: {
|
||||
paddingTop: 5,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
};
|
||||
|
||||
type ChatBubbleProps = {|
|
||||
children: React.Node,
|
||||
feedbackButtons?: React.Node,
|
||||
role: 'assistant' | 'user',
|
||||
|};
|
||||
|
||||
export const ChatBubble = ({
|
||||
children,
|
||||
feedbackButtons,
|
||||
role,
|
||||
}: ChatBubbleProps) => {
|
||||
return (
|
||||
<div className={classes.chatBubbleContainer}>
|
||||
<Paper
|
||||
background={role === 'user' ? 'light' : 'medium'}
|
||||
style={styles.chatBubble}
|
||||
>
|
||||
<div className={classes.chatBubbleContent}>{children}</div>
|
||||
{feedbackButtons}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,35 @@
|
||||
@keyframes chat-bubble-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes content-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
/* Start after the bubble container. */
|
||||
37% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chatBubbleContainer {
|
||||
display: flex;
|
||||
animation: chat-bubble-appear 0.3s;
|
||||
min-width: 0; /** Prevents horizontal overflow */
|
||||
}
|
||||
|
||||
.chatBubbleContent {
|
||||
display: flex;
|
||||
animation: content-appear 0.8s;
|
||||
min-width: 0; /** Prevents horizontal overflow */
|
||||
}
|
@@ -4,8 +4,8 @@ import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { I18n } from '@lingui/react';
|
||||
import classNames from 'classnames';
|
||||
import Window from '../../../Utils/Window';
|
||||
import { getHelpLink } from '../../../Utils/HelpLink';
|
||||
import Window from '../../Utils/Window';
|
||||
import { getHelpLink } from '../../Utils/HelpLink';
|
||||
import classes from './ChatMarkdownText.module.css';
|
||||
import {
|
||||
type ConceptMetadata,
|
||||
@@ -127,7 +127,7 @@ type Props = {|
|
||||
/**
|
||||
* Display a markdown text for a AI chat bubble.
|
||||
*/
|
||||
export const ChatMarkdownText = (props: Props) => {
|
||||
export const ChatMarkdownText = React.memo<Props>((props: Props) => {
|
||||
const { getConceptMetadataFromHref } = useGetConceptMetadata();
|
||||
const renderChatLink = React.useMemo(
|
||||
() => makeRenderChatLink({ getConceptMetadataFromHref }),
|
||||
@@ -169,4 +169,4 @@ export const ChatMarkdownText = (props: Props) => {
|
||||
});
|
||||
|
||||
return <span className={className}>{markdownElement}</span>;
|
||||
};
|
||||
});
|
255
newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js
Normal file
255
newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js
Normal file
@@ -0,0 +1,255 @@
|
||||
// @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
|
||||
) => 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
|
||||
open
|
||||
onClose={() => setDislikeFeedbackDialogOpenedFor(null)}
|
||||
onSendFeedback={(reason: string) => {
|
||||
onSendFeedback(
|
||||
dislikeFeedbackDialogOpenedFor.aiRequestId,
|
||||
dislikeFeedbackDialogOpenedFor.messageIndex,
|
||||
'dislike',
|
||||
reason
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@@ -0,0 +1,26 @@
|
||||
@keyframes thinking-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
/* Start after the bubble container and when the bubble content is almost finished animating. */
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.thinkingText {
|
||||
animation: thinking-appear 1s;
|
||||
}
|
||||
|
||||
.feedbackButtonsContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
@@ -1,22 +1,22 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Window from '../../../Utils/Window';
|
||||
import memoize from '../../../Utils/Memoize';
|
||||
import { useRefWithInit } from '../../../Utils/UseRefInitHook';
|
||||
import { getHelpLink } from '../../../Utils/HelpLink';
|
||||
import { ExtensionStoreContext } from '../../../AssetStore/ExtensionStore/ExtensionStoreContext';
|
||||
import { IconContainer } from '../../../UI/IconContainer';
|
||||
import Window from '../../Utils/Window';
|
||||
import memoize from '../../Utils/Memoize';
|
||||
import { useRefWithInit } from '../../Utils/UseRefInitHook';
|
||||
import { getHelpLink } from '../../Utils/HelpLink';
|
||||
import { ExtensionStoreContext } from '../../AssetStore/ExtensionStore/ExtensionStoreContext';
|
||||
import { IconContainer } from '../../UI/IconContainer';
|
||||
import {
|
||||
type ExtensionShortHeader,
|
||||
type EventsFunctionInsideExtensionShortHeader,
|
||||
} from '../../../Utils/GDevelopServices/Extension';
|
||||
import { mapFor } from '../../../Utils/MapFor';
|
||||
} from '../../Utils/GDevelopServices/Extension';
|
||||
import { mapFor } from '../../Utils/MapFor';
|
||||
import classes from './ChatMarkdownText.module.css';
|
||||
import classNames from 'classnames';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import { MarkdownText } from '../../../UI/MarkdownText';
|
||||
import Text from '../../../UI/Text';
|
||||
import { MarkdownText } from '../../UI/MarkdownText';
|
||||
import Text from '../../UI/Text';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
@@ -1,13 +1,13 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { ColumnStackLayout } from '../../../UI/Layout';
|
||||
import Text from '../../../UI/Text';
|
||||
import { ColumnStackLayout } from '../../UI/Layout';
|
||||
import Text from '../../UI/Text';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Dialog, { DialogPrimaryButton } from '../../../UI/Dialog';
|
||||
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 FlatButton from '../../UI/FlatButton';
|
||||
|
||||
type DislikeFeedbackDialogProps = {|
|
||||
open: boolean,
|
214
newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js
Normal file
214
newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js
Normal file
@@ -0,0 +1,214 @@
|
||||
// @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 RaisedButtonWithSplitMenu from '../../UI/RaisedButtonWithSplitMenu';
|
||||
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 } 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) {
|
||||
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>
|
||||
<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>
|
||||
{!isFinished && (
|
||||
<RaisedButtonWithSplitMenu
|
||||
primary
|
||||
disabled={isWorking}
|
||||
onClick={() => onProcessFunctionCalls([functionCall])}
|
||||
label={<Trans>Apply</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>
|
||||
{details && (
|
||||
<div className={classes.detailsPaperContainer}>
|
||||
<Paper background="medium" elevation={0} square variant="outlined">
|
||||
<Line expand>
|
||||
<Column expand>
|
||||
<Text noMargin color="secondary">
|
||||
{details}
|
||||
</Text>
|
||||
</Column>
|
||||
</Line>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
@@ -0,0 +1,38 @@
|
||||
@keyframes function-call-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
/* Start after the bubble container and when the bubble content is almost finished animating. */
|
||||
60% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.functionCallContainer {
|
||||
animation: function-call-appear 0.5s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@keyframes details-paper-appear {
|
||||
0% {
|
||||
transform: scaleY(0.9);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
.detailsPaperContainer {
|
||||
transform-origin: top left;
|
||||
animation: details-paper-appear 0.1s;
|
||||
}
|
717
newIDE/app/src/AiGeneration/AiRequestChat/index.js
Normal file
717
newIDE/app/src/AiGeneration/AiRequestChat/index.js
Normal file
@@ -0,0 +1,717 @@
|
||||
// @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';
|
||||
|
||||
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
|
||||
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
|
||||
|
||||
const styles = {
|
||||
chatScrollView: {
|
||||
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
|
||||
) => Promise<void>,
|
||||
hasOpenedProject: boolean,
|
||||
isAutoProcessingFunctionCalls: boolean,
|
||||
setAutoProcessFunctionCalls: boolean => void,
|
||||
|
||||
onProcessFunctionCalls: (
|
||||
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
|
||||
options: ?{| ignore?: boolean |}
|
||||
) => Promise<void>,
|
||||
editorFunctionCallResults: Array<EditorFunctionCallResult> | null,
|
||||
editorCallbacks: EditorCallbacks,
|
||||
// Error that occurred while sending the last request.
|
||||
lastSendError: ?Error,
|
||||
|
||||
// Quota available for using the feature.
|
||||
quota: Quota | null,
|
||||
increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none',
|
||||
price: UsagePrice | null,
|
||||
availableCredits: number,
|
||||
};
|
||||
|
||||
export type AiRequestChatInterface = {|
|
||||
resetUserInput: (aiRequestId: string | null) => void,
|
||||
|};
|
||||
|
||||
const getQuotaOrCreditsText = ({
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
price,
|
||||
availableCredits,
|
||||
isMobile,
|
||||
}: {|
|
||||
quota: Quota | null,
|
||||
increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none',
|
||||
price: UsagePrice | null,
|
||||
availableCredits: number,
|
||||
isMobile: boolean,
|
||||
|}) => {
|
||||
if (!quota) return null;
|
||||
|
||||
const quotaOrCreditsText = !quota.limitReached ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
{increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
Get GDevelop premium to get more free requests every month.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
These are parts of your GDevelop premium membership ({quota.max}{' '}
|
||||
free requests per month).
|
||||
</Trans>
|
||||
)}{' '}
|
||||
<Trans>Free requests do not consume credits on your account.</Trans>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{isMobile ? (
|
||||
<Trans>{quota.max - quota.current} free requests left</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{quota.max - quota.current} of {quota.max} free requests left this
|
||||
month
|
||||
</Trans>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Trans>{Math.max(0, availableCredits)} credits available</Trans>
|
||||
);
|
||||
|
||||
return quotaOrCreditsText;
|
||||
};
|
||||
|
||||
const getPriceText = ({
|
||||
aiRequestMode,
|
||||
price,
|
||||
}: {|
|
||||
aiRequestMode: 'chat' | 'agent',
|
||||
price: UsagePrice | null,
|
||||
|}) => {
|
||||
if (!price) return null;
|
||||
|
||||
const priceInCredits = price.priceInCredits;
|
||||
const maximumPriceInCredits =
|
||||
(price.variablePrice &&
|
||||
price.variablePrice[aiRequestMode] &&
|
||||
price.variablePrice[aiRequestMode].maximumPriceInCredits) ||
|
||||
null;
|
||||
|
||||
const priceText = maximumPriceInCredits ? (
|
||||
<Trans>
|
||||
{priceInCredits} to {maximumPriceInCredits}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>{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>
|
||||
) : (
|
||||
<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,
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
lastSendError,
|
||||
price,
|
||||
availableCredits,
|
||||
hasOpenedProject,
|
||||
editorFunctionCallResults,
|
||||
onProcessFunctionCalls,
|
||||
isAutoProcessingFunctionCalls,
|
||||
setAutoProcessFunctionCalls,
|
||||
i18n,
|
||||
editorCallbacks,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
// TODO: store the default mode in the user preferences?
|
||||
const [newAiRequestMode, setNewAiRequestMode] = React.useState<
|
||||
'chat' | 'agent'
|
||||
>('agent');
|
||||
const aiRequestId: string = aiRequest ? aiRequest.id : '';
|
||||
const [
|
||||
userRequestTextPerAiRequestId,
|
||||
setUserRequestTextPerRequestId,
|
||||
] = React.useState<{ [string]: string }>({});
|
||||
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
|
||||
|
||||
const newChatPlaceholder = React.useMemo(
|
||||
() => {
|
||||
const newChatPlaceholders: Array<MessageDescriptor> =
|
||||
newAiRequestMode === 'agent'
|
||||
? hasOpenedProject
|
||||
? [
|
||||
t`Add an enemy that spawns periodically`,
|
||||
t`Display the score on the screen`,
|
||||
t`Show an 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,
|
||||
}) || '\u00A0'}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const subscriptionBanner =
|
||||
quota && quota.limitReached && increaseQuotaOffering !== 'none' ? (
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason={
|
||||
increaseQuotaOffering === 'subscribe'
|
||||
? 'AI requests (subscribe)'
|
||||
: 'AI requests (upgrade)'
|
||||
}
|
||||
label={
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>Get GDevelop premium</Trans>
|
||||
) : (
|
||||
<Trans>Upgrade</Trans>
|
||||
)
|
||||
}
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_gold"
|
||||
canHide
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
<Text noMargin>
|
||||
{increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
Unlock AI requests included with a GDevelop premium plan.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Get even more AI requests included with a higher premium
|
||||
plan.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Line>
|
||||
</GetSubscriptionCard>
|
||||
) : null;
|
||||
|
||||
const 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={classes.newChatContainer}>
|
||||
<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}
|
||||
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}
|
||||
</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/ask-ai')}
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(getHelpLink('/interface/ask-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/ask-ai')}
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(getHelpLink('/interface/ask-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,
|
||||
});
|
||||
|
||||
return (
|
||||
<Column
|
||||
expand
|
||||
alignItems="stretch"
|
||||
justifyContent="stretch"
|
||||
useFullHeight
|
||||
>
|
||||
<ScrollView ref={scrollViewRef} style={styles.chatScrollView}>
|
||||
<ChatMessages
|
||||
aiRequest={aiRequest}
|
||||
onSendFeedback={onSendFeedback}
|
||||
editorFunctionCallResults={editorFunctionCallResults}
|
||||
editorCallbacks={editorCallbacks}
|
||||
project={project}
|
||||
onProcessFunctionCalls={onProcessFunctionCalls}
|
||||
/>
|
||||
{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
|
||||
)}
|
||||
</ScrollView>
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onSendMessage({
|
||||
userMessage: userRequestTextPerAiRequestId[aiRequestId] || '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout
|
||||
justifyContent="stretch"
|
||||
alignItems="stretch"
|
||||
noMargin
|
||||
>
|
||||
{isAutoProcessingFunctionCalls &&
|
||||
(hasWorkingFunctionCalls ||
|
||||
isSending ||
|
||||
aiRequest.status === 'working') ? (
|
||||
<Paper background="dark" variant="outlined" square>
|
||||
<Column>
|
||||
<LineStackLayout
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<LineStackLayout alignItems="center" noMargin>
|
||||
<CircularProgress variant="indeterminate" size={10} />
|
||||
<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>
|
||||
) : !isAutoProcessingFunctionCalls &&
|
||||
allFunctionCallsToProcess.length > 0 ? (
|
||||
<Paper background="dark" variant="outlined" square>
|
||||
<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>
|
||||
Apply everything and continue autonomously
|
||||
</Trans>
|
||||
</Link>
|
||||
</Text>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</Paper>
|
||||
) : null}
|
||||
<CompactTextAreaFieldWithControls
|
||||
maxLength={6000}
|
||||
value={userRequestTextPerAiRequestId[aiRequestId] || ''}
|
||||
disabled={isSending}
|
||||
errored={!!lastSendError}
|
||||
onChange={userRequestText =>
|
||||
setUserRequestTextPerRequestId(
|
||||
userRequestTextPerAiRequestId => ({
|
||||
...userRequestTextPerAiRequestId,
|
||||
[aiRequestId]: userRequestText,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
aiRequest.mode === 'agent'
|
||||
? 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 ||
|
||||
!userRequestTextPerAiRequestId[aiRequestId]
|
||||
}
|
||||
icon={<Send />}
|
||||
onClick={() => {
|
||||
onSendMessage({
|
||||
userMessage:
|
||||
userRequestTextPerAiRequestId[aiRequestId] || '',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
}
|
||||
/>
|
||||
<Column noMargin alignItems="stretch">
|
||||
<LineStackLayout
|
||||
expand
|
||||
noMargin
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{errorText || priceText}
|
||||
{errorText ? null : quotaOrCreditsText}
|
||||
</LineStackLayout>
|
||||
</Column>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
);
|
987
newIDE/app/src/AiGeneration/AskAiEditorContainer.js
Normal file
987
newIDE/app/src/AiGeneration/AskAiEditorContainer.js
Normal file
@@ -0,0 +1,987 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { type RenderEditorContainerPropsWithRef } from '../MainFrame/EditorContainers/BaseEditor';
|
||||
import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
|
||||
import Paper from '../UI/Paper';
|
||||
import { AiRequestChat, type AiRequestChatInterface } from './AiRequestChat';
|
||||
import {
|
||||
addMessageToAiRequest,
|
||||
createAiRequest,
|
||||
getAiRequest,
|
||||
sendAiRequestFeedback,
|
||||
type AiRequest,
|
||||
type AiRequestMessageAssistantFunctionCall,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import { delay } from '../Utils/Delay';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import { AskAiHistory } from './AskAiHistory';
|
||||
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
|
||||
import {
|
||||
canUpgradeSubscription,
|
||||
hasValidSubscriptionPlan,
|
||||
} from '../Utils/GDevelopServices/Usage';
|
||||
import { retryIfFailed } from '../Utils/RetryIfFailed';
|
||||
import { CreditsPackageStoreContext } from '../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
import {
|
||||
processEditorFunctionCalls,
|
||||
type EditorFunctionCallResult,
|
||||
} from '../EditorFunctions/EditorFunctionCallRunner';
|
||||
import { type EditorCallbacks } from '../EditorFunctions';
|
||||
import {
|
||||
getFunctionCallOutputsFromEditorFunctionCallResults,
|
||||
getFunctionCallsToProcess,
|
||||
} from './AiRequestChat/AiRequestUtils';
|
||||
import { useStableUpToDateRef } from '../Utils/UseStableUpToDateCallback';
|
||||
import { useTriggerAtNextRender } from '../Utils/useTriggerAtNextRender';
|
||||
import {
|
||||
generateProjectName,
|
||||
type NewProjectSetup,
|
||||
} from '../ProjectCreation/NewProjectSetupDialog';
|
||||
import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider';
|
||||
import { type FileMetadata, type StorageProvider } from '../ProjectsStorage';
|
||||
import { useEnsureExtensionInstalled } from './UseEnsureExtensionInstalled';
|
||||
import { useGenerateEvents } from './UseGenerateEvents';
|
||||
import { useSearchAndInstallAsset } from './UseSearchAndInstallAsset';
|
||||
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const useEditorFunctionCallResultsPerRequest = () => {
|
||||
const [
|
||||
editorFunctionCallResultsPerRequest,
|
||||
setEditorFunctionCallResultsPerRequest,
|
||||
] = React.useState<{
|
||||
[aiRequestId: string]: Array<EditorFunctionCallResult>,
|
||||
}>({});
|
||||
|
||||
return {
|
||||
getEditorFunctionCallResults: React.useCallback(
|
||||
(aiRequestId: string): Array<EditorFunctionCallResult> | null =>
|
||||
editorFunctionCallResultsPerRequest[aiRequestId] || null,
|
||||
[editorFunctionCallResultsPerRequest]
|
||||
),
|
||||
addEditorFunctionCallResults: React.useCallback(
|
||||
(
|
||||
aiRequestId: string,
|
||||
editorFunctionCallResults: EditorFunctionCallResult[]
|
||||
) => {
|
||||
setEditorFunctionCallResultsPerRequest(
|
||||
editorFunctionCallResultsPerRequest => {
|
||||
const existingEditorFunctionCallResults = (
|
||||
editorFunctionCallResultsPerRequest[aiRequestId] || []
|
||||
).filter(existingEditorFunctionCallResult => {
|
||||
return !editorFunctionCallResults.some(
|
||||
editorFunctionCallResult => {
|
||||
return (
|
||||
editorFunctionCallResult.call_id ===
|
||||
existingEditorFunctionCallResult.call_id
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...editorFunctionCallResultsPerRequest,
|
||||
[aiRequestId]: [
|
||||
...existingEditorFunctionCallResults,
|
||||
...editorFunctionCallResults,
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
[]
|
||||
),
|
||||
clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => {
|
||||
setEditorFunctionCallResultsPerRequest(
|
||||
editorFunctionCallResultsPerRequest => ({
|
||||
...editorFunctionCallResultsPerRequest,
|
||||
[aiRequestId]: null,
|
||||
})
|
||||
);
|
||||
}, []),
|
||||
};
|
||||
};
|
||||
|
||||
const useProcessFunctionCalls = ({
|
||||
i18n,
|
||||
project,
|
||||
resourceManagementProps,
|
||||
selectedAiRequest,
|
||||
onSendEditorFunctionCallResults,
|
||||
getEditorFunctionCallResults,
|
||||
addEditorFunctionCallResults,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
}: {|
|
||||
i18n: I18nType,
|
||||
project: gdProject | null,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
selectedAiRequest: ?AiRequest,
|
||||
onSendEditorFunctionCallResults: () => Promise<void>,
|
||||
getEditorFunctionCallResults: string => Array<EditorFunctionCallResult> | null,
|
||||
addEditorFunctionCallResults: (
|
||||
string,
|
||||
Array<EditorFunctionCallResult>
|
||||
) => void,
|
||||
onSceneEventsModifiedOutsideEditor: (scene: gdLayout) => void,
|
||||
|}) => {
|
||||
const { ensureExtensionInstalled } = useEnsureExtensionInstalled({
|
||||
project,
|
||||
i18n,
|
||||
});
|
||||
const { searchAndInstallAsset } = useSearchAndInstallAsset({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
});
|
||||
const { generateEvents } = useGenerateEvents({ project });
|
||||
|
||||
const triggerSendEditorFunctionCallResults = useTriggerAtNextRender(
|
||||
onSendEditorFunctionCallResults
|
||||
);
|
||||
|
||||
const [
|
||||
aiRequestAutoProcessState,
|
||||
setAiRequestAutoprocessState,
|
||||
] = React.useState<{
|
||||
[string]: boolean,
|
||||
}>({});
|
||||
const isAutoProcessingFunctionCalls = React.useCallback(
|
||||
(aiRequestId: string) =>
|
||||
aiRequestAutoProcessState[aiRequestId] !== undefined
|
||||
? aiRequestAutoProcessState[aiRequestId]
|
||||
: true,
|
||||
[aiRequestAutoProcessState]
|
||||
);
|
||||
|
||||
const setAutoProcessFunctionCalls = React.useCallback(
|
||||
(aiRequestId: string, shouldAutoProcess: boolean) => {
|
||||
setAiRequestAutoprocessState(aiRequestAutoProcessState => ({
|
||||
...aiRequestAutoProcessState,
|
||||
[aiRequestId]: shouldAutoProcess,
|
||||
}));
|
||||
},
|
||||
[setAiRequestAutoprocessState]
|
||||
);
|
||||
|
||||
const onProcessFunctionCalls = React.useCallback(
|
||||
async (
|
||||
functionCalls: Array<AiRequestMessageAssistantFunctionCall>,
|
||||
options: ?{|
|
||||
ignore?: boolean,
|
||||
|}
|
||||
) => {
|
||||
if (!project || !selectedAiRequest) return;
|
||||
|
||||
addEditorFunctionCallResults(
|
||||
selectedAiRequest.id,
|
||||
functionCalls.map(functionCall => ({
|
||||
status: 'working',
|
||||
call_id: functionCall.call_id,
|
||||
}))
|
||||
);
|
||||
|
||||
const editorFunctionCallResults = await processEditorFunctionCalls({
|
||||
project,
|
||||
functionCalls: functionCalls.map(functionCall => ({
|
||||
name: functionCall.name,
|
||||
arguments: functionCall.arguments,
|
||||
call_id: functionCall.call_id,
|
||||
})),
|
||||
ignore: !!options && !!options.ignore,
|
||||
generateEvents: async options => {
|
||||
return await generateEvents({
|
||||
...options,
|
||||
relatedAiRequestId: selectedAiRequest.id,
|
||||
});
|
||||
},
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
ensureExtensionInstalled,
|
||||
searchAndInstallAsset,
|
||||
});
|
||||
|
||||
addEditorFunctionCallResults(
|
||||
selectedAiRequest.id,
|
||||
editorFunctionCallResults
|
||||
);
|
||||
|
||||
// We may have processed everything, so try to send the results
|
||||
// to the backend.
|
||||
triggerSendEditorFunctionCallResults();
|
||||
},
|
||||
[
|
||||
project,
|
||||
selectedAiRequest,
|
||||
addEditorFunctionCallResults,
|
||||
ensureExtensionInstalled,
|
||||
searchAndInstallAsset,
|
||||
generateEvents,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
triggerSendEditorFunctionCallResults,
|
||||
]
|
||||
);
|
||||
|
||||
const allFunctionCallsToProcess = React.useMemo(
|
||||
() =>
|
||||
selectedAiRequest
|
||||
? getFunctionCallsToProcess({
|
||||
aiRequest: selectedAiRequest,
|
||||
editorFunctionCallResults: getEditorFunctionCallResults(
|
||||
selectedAiRequest.id
|
||||
),
|
||||
})
|
||||
: [],
|
||||
[selectedAiRequest, getEditorFunctionCallResults]
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
(async () => {
|
||||
if (!selectedAiRequest) return;
|
||||
|
||||
if (isAutoProcessingFunctionCalls(selectedAiRequest.id)) {
|
||||
if (allFunctionCallsToProcess.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.info('Automatically processing AI function calls...');
|
||||
await onProcessFunctionCalls(allFunctionCallsToProcess);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
selectedAiRequest,
|
||||
isAutoProcessingFunctionCalls,
|
||||
onProcessFunctionCalls,
|
||||
allFunctionCallsToProcess,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
isAutoProcessingFunctionCalls,
|
||||
setAutoProcessFunctionCalls,
|
||||
onProcessFunctionCalls,
|
||||
};
|
||||
};
|
||||
|
||||
type AiRequestSendState = {|
|
||||
isSending: boolean,
|
||||
lastSendError: ?Error,
|
||||
|};
|
||||
|
||||
export const useAiRequests = () => {
|
||||
const { profile, getAuthorizationHeader } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const [aiRequests, setAiRequests] = React.useState<{ [string]: AiRequest }>(
|
||||
{}
|
||||
);
|
||||
const [selectedAiRequestId, setSelectedAiRequestId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const updateAiRequest = React.useCallback(
|
||||
(aiRequestId: string, aiRequest: AiRequest) => {
|
||||
setAiRequests(aiRequests => ({
|
||||
...aiRequests,
|
||||
[aiRequestId]: aiRequest,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refreshAiRequest = React.useCallback(
|
||||
async (aiRequestId: string) => {
|
||||
if (!profile) return;
|
||||
|
||||
try {
|
||||
const updatedAiRequest = await getAiRequest(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId: aiRequestId,
|
||||
});
|
||||
updateAiRequest(updatedAiRequest.id, updatedAiRequest);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error while background refreshing AI request - ignoring:',
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
[getAuthorizationHeader, profile, updateAiRequest]
|
||||
);
|
||||
|
||||
const selectedAiRequest =
|
||||
(selectedAiRequestId && aiRequests[selectedAiRequestId]) || null;
|
||||
|
||||
// If the selected AI request is in a "working" state, watch it until it's finished.
|
||||
const status = selectedAiRequest ? selectedAiRequest.status : null;
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (!profile) return;
|
||||
if (!selectedAiRequestId || !status) return;
|
||||
|
||||
let stopWatching = false;
|
||||
|
||||
const watch = async () => {
|
||||
while (true) {
|
||||
await delay(1000);
|
||||
if (stopWatching) return;
|
||||
|
||||
const aiRequest = await getAiRequest(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId: selectedAiRequestId,
|
||||
});
|
||||
if (stopWatching) return;
|
||||
|
||||
updateAiRequest(selectedAiRequestId, aiRequest);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'working') {
|
||||
console.info(`Started watching AI request ${selectedAiRequestId}.`);
|
||||
watch();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (status === 'working') {
|
||||
console.info(`Stopped watching AI request ${selectedAiRequestId}.`);
|
||||
}
|
||||
stopWatching = true;
|
||||
};
|
||||
},
|
||||
[
|
||||
selectedAiRequestId,
|
||||
status,
|
||||
profile,
|
||||
getAuthorizationHeader,
|
||||
updateAiRequest,
|
||||
]
|
||||
);
|
||||
|
||||
const [aiRequestSendStates, setAiRequestSendStates] = React.useState<{
|
||||
[string]: AiRequestSendState,
|
||||
}>({});
|
||||
const isSendingAiRequest = React.useCallback(
|
||||
(aiRequestId: string | null) =>
|
||||
!!aiRequestSendStates[aiRequestId || ''] &&
|
||||
aiRequestSendStates[aiRequestId || ''].isSending,
|
||||
[aiRequestSendStates]
|
||||
);
|
||||
const getLastSendError = React.useCallback(
|
||||
(aiRequestId: string | null) =>
|
||||
(aiRequestSendStates[aiRequestId || ''] &&
|
||||
aiRequestSendStates[aiRequestId || ''].lastSendError) ||
|
||||
null,
|
||||
[aiRequestSendStates]
|
||||
);
|
||||
const setSendingAiRequest = React.useCallback(
|
||||
(aiRequestId: string | null, isSending: boolean) => {
|
||||
const aiRequestIdToSet: string = aiRequestId || '';
|
||||
setAiRequestSendStates(aiRequestSendStates => ({
|
||||
...aiRequestSendStates,
|
||||
[aiRequestIdToSet]: {
|
||||
isSending,
|
||||
lastSendError: null,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setAiRequestSendStates]
|
||||
);
|
||||
const setLastSendError = React.useCallback(
|
||||
(aiRequestId: string | null, lastSendError: ?Error) => {
|
||||
const aiRequestIdToSet: string = aiRequestId || '';
|
||||
setAiRequestSendStates(aiRequestSendStates => ({
|
||||
...aiRequestSendStates,
|
||||
[aiRequestIdToSet]: {
|
||||
isSending: false,
|
||||
lastSendError,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[setAiRequestSendStates]
|
||||
);
|
||||
|
||||
return {
|
||||
selectedAiRequest,
|
||||
selectedAiRequestId,
|
||||
setSelectedAiRequestId,
|
||||
updateAiRequest,
|
||||
refreshAiRequest,
|
||||
isSendingAiRequest,
|
||||
setSendingAiRequest,
|
||||
setLastSendError,
|
||||
getLastSendError,
|
||||
};
|
||||
};
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
chatContainer: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: 'min(100%, 800px)',
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
marginBottom: 10,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
isActive: boolean,
|
||||
project: gdProject | null,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
fileMetadata: ?FileMetadata,
|
||||
storageProvider: ?StorageProvider,
|
||||
setToolbar: (?React.Node) => void,
|
||||
i18n: I18nType,
|
||||
onCreateEmptyProject: (newProjectSetup: NewProjectSetup) => Promise<void>,
|
||||
onOpenLayout: (sceneName: string) => void,
|
||||
onOpenEvents: (sceneName: string) => void,
|
||||
onSceneEventsModifiedOutsideEditor: (scene: gdLayout) => void,
|
||||
|};
|
||||
|
||||
export type AskAiEditorInterface = {|
|
||||
getProject: () => void,
|
||||
updateToolbar: () => void,
|
||||
forceUpdateEditor: () => void,
|
||||
onEventsBasedObjectChildrenEdited: () => void,
|
||||
onSceneObjectEdited: (
|
||||
scene: gdLayout,
|
||||
objectWithContext: ObjectWithContext
|
||||
) => void,
|
||||
onSceneObjectsDeleted: (scene: gdLayout) => void,
|
||||
onSceneEventsModifiedOutsideEditor: (scene: gdLayout) => void,
|
||||
|};
|
||||
|
||||
export type NewAiRequestOptions = {|
|
||||
mode: 'chat' | 'agent',
|
||||
userRequest: string,
|
||||
|};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const AskAiEditor = React.memo<Props>(
|
||||
React.forwardRef<Props, AskAiEditorInterface>(
|
||||
(
|
||||
{
|
||||
isActive,
|
||||
setToolbar,
|
||||
project,
|
||||
resourceManagementProps,
|
||||
fileMetadata,
|
||||
storageProvider,
|
||||
i18n,
|
||||
onCreateEmptyProject,
|
||||
onOpenLayout,
|
||||
onOpenEvents,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
const editorCallbacks: EditorCallbacks = React.useMemo(
|
||||
() => ({
|
||||
onOpenLayout,
|
||||
onOpenEvents,
|
||||
}),
|
||||
[onOpenLayout, onOpenEvents]
|
||||
);
|
||||
|
||||
const {
|
||||
selectedAiRequest,
|
||||
selectedAiRequestId,
|
||||
setSelectedAiRequestId,
|
||||
updateAiRequest,
|
||||
refreshAiRequest,
|
||||
setSendingAiRequest,
|
||||
isSendingAiRequest,
|
||||
getLastSendError,
|
||||
setLastSendError,
|
||||
} = useAiRequests();
|
||||
const upToDateSelectedAiRequestId = useStableUpToDateRef(
|
||||
selectedAiRequestId
|
||||
);
|
||||
|
||||
const [
|
||||
newAiRequestOptions,
|
||||
startNewAiRequest,
|
||||
] = React.useState<NewAiRequestOptions | null>(null);
|
||||
|
||||
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
|
||||
|
||||
const canStartNewChat = !!selectedAiRequestId;
|
||||
const onStartNewChat = React.useCallback(
|
||||
() => {
|
||||
setSelectedAiRequestId(null);
|
||||
},
|
||||
[setSelectedAiRequestId]
|
||||
);
|
||||
|
||||
const onOpenHistory = React.useCallback(() => {
|
||||
setIsHistoryOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
setIsHistoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
getEditorFunctionCallResults,
|
||||
addEditorFunctionCallResults,
|
||||
clearEditorFunctionCallResults,
|
||||
} = useEditorFunctionCallResultsPerRequest();
|
||||
|
||||
const updateToolbar = React.useCallback(
|
||||
() => {
|
||||
if (setToolbar) {
|
||||
setToolbar(
|
||||
<Toolbar
|
||||
onStartNewChat={onStartNewChat}
|
||||
canStartNewChat={canStartNewChat}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[setToolbar, onStartNewChat, canStartNewChat, onOpenHistory]
|
||||
);
|
||||
|
||||
React.useEffect(updateToolbar, [updateToolbar]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getProject: noop,
|
||||
updateToolbar,
|
||||
forceUpdateEditor: noop,
|
||||
onEventsBasedObjectChildrenEdited: noop,
|
||||
onSceneObjectEdited: noop,
|
||||
onSceneObjectsDeleted: noop,
|
||||
onSceneEventsModifiedOutsideEditor: noop,
|
||||
}));
|
||||
|
||||
const aiRequestChatRef = React.useRef<AiRequestChatInterface | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const { openCreditsPackageDialog } = React.useContext(
|
||||
CreditsPackageStoreContext
|
||||
);
|
||||
|
||||
const {
|
||||
profile,
|
||||
getAuthorizationHeader,
|
||||
onOpenCreateAccountDialog,
|
||||
limits,
|
||||
onRefreshLimits,
|
||||
subscription,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const availableCredits = limits ? limits.credits.userBalance.amount : 0;
|
||||
const quota =
|
||||
(limits && limits.quotas && limits.quotas['ai-request']) || null;
|
||||
const aiRequestPrice =
|
||||
(limits && limits.credits && limits.credits.prices['ai-request']) ||
|
||||
null;
|
||||
const aiRequestPriceInCredits = aiRequestPrice
|
||||
? aiRequestPrice.priceInCredits
|
||||
: null;
|
||||
|
||||
// Refresh limits when navigating ot this tab, as we want to be sure
|
||||
// we display the proper quota and credits information for the user.
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (isActive) {
|
||||
onRefreshLimits();
|
||||
}
|
||||
},
|
||||
[isActive, onRefreshLimits]
|
||||
);
|
||||
|
||||
// Trigger the start of the new AI request if the user has requested it
|
||||
// (or if triggered automatically by setting `newAiRequestOptions`, for example
|
||||
// after waiting for the project to be created for an AI agent request).
|
||||
React.useEffect(
|
||||
() => {
|
||||
(async () => {
|
||||
if (!newAiRequestOptions) return;
|
||||
console.info('Starting a new AI request...');
|
||||
|
||||
if (!profile) {
|
||||
onOpenCreateAccountDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the options and reset them (to avoid launching the same request twice).
|
||||
const { mode, userRequest } = newAiRequestOptions;
|
||||
startNewAiRequest(null);
|
||||
|
||||
// If no project is opened, create a new empty one if the request is for
|
||||
// the AI agent.
|
||||
if (mode === 'agent' && !project) {
|
||||
try {
|
||||
console.info('No project opened, creating a new empty one.');
|
||||
await onCreateEmptyProject({
|
||||
projectName: generateProjectName('AI starter'),
|
||||
storageProvider: UrlStorageProvider,
|
||||
saveAsLocation: null,
|
||||
dontOpenAnySceneOrProjectManager: true,
|
||||
});
|
||||
startNewAiRequest({
|
||||
mode,
|
||||
userRequest,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating a new empty project:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the user has enough credits to pay for the request, or ask them
|
||||
// to buy some more.
|
||||
let payWithCredits = false;
|
||||
if (quota && quota.limitReached && aiRequestPriceInCredits) {
|
||||
payWithCredits = true;
|
||||
if (availableCredits < aiRequestPriceInCredits) {
|
||||
openCreditsPackageDialog({
|
||||
missingCredits: aiRequestPriceInCredits - availableCredits,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Request is now ready to be started.
|
||||
try {
|
||||
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
|
||||
const simplifiedProjectJson = project
|
||||
? JSON.stringify(
|
||||
simplifiedProjectBuilder.getSimplifiedProject(project, {})
|
||||
)
|
||||
: null;
|
||||
const projectSpecificExtensionsSummaryJson = project
|
||||
? JSON.stringify(
|
||||
simplifiedProjectBuilder.getProjectSpecificExtensionsSummary(
|
||||
project
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
setSendingAiRequest(null, true);
|
||||
|
||||
const aiRequest = await createAiRequest(getAuthorizationHeader, {
|
||||
userRequest: userRequest,
|
||||
userId: profile.id,
|
||||
gameProjectJson: simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
payWithCredits,
|
||||
gameId: project ? project.getProjectUuid() : null,
|
||||
fileMetadata,
|
||||
storageProviderName: storageProvider
|
||||
? storageProvider.internalName
|
||||
: null,
|
||||
mode,
|
||||
});
|
||||
|
||||
console.info('Successfully created a new AI request:', aiRequest);
|
||||
setSendingAiRequest(null, false);
|
||||
updateAiRequest(aiRequest.id, aiRequest);
|
||||
|
||||
// Select the new AI request just created - unless the user switched to another one
|
||||
// in the meantime.
|
||||
if (!upToDateSelectedAiRequestId.current) {
|
||||
setSelectedAiRequestId(aiRequest.id);
|
||||
}
|
||||
|
||||
if (aiRequestChatRef.current)
|
||||
aiRequestChatRef.current.resetUserInput(selectedAiRequestId);
|
||||
} catch (error) {
|
||||
console.error('Error starting a new AI request:', error);
|
||||
setLastSendError(null, error);
|
||||
}
|
||||
|
||||
// Refresh the user limits, to ensure quota and credits information
|
||||
// is up-to-date after an AI request.
|
||||
await delay(500);
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, onRefreshLimits);
|
||||
} catch (error) {
|
||||
// Ignore limits refresh error.
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
aiRequestPriceInCredits,
|
||||
availableCredits,
|
||||
getAuthorizationHeader,
|
||||
onOpenCreateAccountDialog,
|
||||
onRefreshLimits,
|
||||
openCreditsPackageDialog,
|
||||
profile,
|
||||
project,
|
||||
fileMetadata,
|
||||
storageProvider,
|
||||
quota,
|
||||
selectedAiRequestId,
|
||||
setLastSendError,
|
||||
setSelectedAiRequestId,
|
||||
setSendingAiRequest,
|
||||
upToDateSelectedAiRequestId,
|
||||
updateAiRequest,
|
||||
onCreateEmptyProject,
|
||||
newAiRequestOptions,
|
||||
]
|
||||
);
|
||||
|
||||
// Send the results of the function call outputs, if any, and the user message (if any).
|
||||
const onSendMessage = React.useCallback(
|
||||
async ({ userMessage }: {| userMessage: string |}) => {
|
||||
if (
|
||||
!profile ||
|
||||
!selectedAiRequestId ||
|
||||
isSendingAiRequest(selectedAiRequestId)
|
||||
)
|
||||
return;
|
||||
|
||||
// Read the results from the editor that applied the function calls.
|
||||
// and transform them into the output that will be stored on the AI request.
|
||||
const {
|
||||
hasUnfinishedResult,
|
||||
functionCallOutputs,
|
||||
} = getFunctionCallOutputsFromEditorFunctionCallResults(
|
||||
getEditorFunctionCallResults(selectedAiRequestId)
|
||||
);
|
||||
|
||||
// If anything is not finished yet, stop there (we only send all
|
||||
// results at once, AI do not support partial results).
|
||||
if (hasUnfinishedResult) return;
|
||||
|
||||
// If nothing to send, stop there.
|
||||
if (functionCallOutputs.length === 0 && !userMessage) return;
|
||||
|
||||
// Paying with credits is only when a user message is sent (and quota is exhausted).
|
||||
let payWithCredits = false;
|
||||
if (
|
||||
userMessage &&
|
||||
quota &&
|
||||
quota.limitReached &&
|
||||
aiRequestPriceInCredits
|
||||
) {
|
||||
payWithCredits = true;
|
||||
if (availableCredits < aiRequestPriceInCredits) {
|
||||
openCreditsPackageDialog({
|
||||
missingCredits: aiRequestPriceInCredits - availableCredits,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSendingAiRequest(selectedAiRequestId, true);
|
||||
|
||||
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
|
||||
const simplifiedProjectJson = project
|
||||
? JSON.stringify(
|
||||
simplifiedProjectBuilder.getSimplifiedProject(project, {})
|
||||
)
|
||||
: null;
|
||||
const projectSpecificExtensionsSummaryJson = project
|
||||
? JSON.stringify(
|
||||
simplifiedProjectBuilder.getProjectSpecificExtensionsSummary(
|
||||
project
|
||||
)
|
||||
)
|
||||
: null;
|
||||
|
||||
const aiRequest = await retryIfFailed({ times: 2 }, () =>
|
||||
addMessageToAiRequest(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId: selectedAiRequestId,
|
||||
functionCallOutputs,
|
||||
gameProjectJson: simplifiedProjectJson,
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
payWithCredits,
|
||||
userMessage,
|
||||
})
|
||||
);
|
||||
updateAiRequest(aiRequest.id, aiRequest);
|
||||
setSendingAiRequest(aiRequest.id, false);
|
||||
clearEditorFunctionCallResults(aiRequest.id);
|
||||
} catch (error) {
|
||||
// TODO: update the label of the button to send again.
|
||||
setLastSendError(selectedAiRequestId, error);
|
||||
}
|
||||
|
||||
if (userMessage) {
|
||||
if (aiRequestChatRef.current)
|
||||
aiRequestChatRef.current.resetUserInput(selectedAiRequestId);
|
||||
|
||||
// Refresh the user limits, to ensure quota and credits information
|
||||
// is up-to-date after an AI request.
|
||||
await delay(500);
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, onRefreshLimits);
|
||||
} catch (error) {
|
||||
// Ignore limits refresh error.
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
profile,
|
||||
selectedAiRequestId,
|
||||
isSendingAiRequest,
|
||||
getEditorFunctionCallResults,
|
||||
quota,
|
||||
aiRequestPriceInCredits,
|
||||
availableCredits,
|
||||
openCreditsPackageDialog,
|
||||
setSendingAiRequest,
|
||||
updateAiRequest,
|
||||
clearEditorFunctionCallResults,
|
||||
getAuthorizationHeader,
|
||||
setLastSendError,
|
||||
onRefreshLimits,
|
||||
project,
|
||||
]
|
||||
);
|
||||
const onSendEditorFunctionCallResults = React.useCallback(
|
||||
() =>
|
||||
onSendMessage({
|
||||
userMessage: '',
|
||||
}),
|
||||
[onSendMessage]
|
||||
);
|
||||
|
||||
const onSendFeedback = React.useCallback(
|
||||
async (aiRequestId, messageIndex, feedback, reason) => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, () =>
|
||||
sendAiRequestFeedback(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId,
|
||||
messageIndex,
|
||||
feedback,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error sending feedback: ', error);
|
||||
}
|
||||
},
|
||||
[getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
const {
|
||||
isAutoProcessingFunctionCalls,
|
||||
setAutoProcessFunctionCalls,
|
||||
onProcessFunctionCalls,
|
||||
} = useProcessFunctionCalls({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
selectedAiRequest,
|
||||
onSendEditorFunctionCallResults,
|
||||
getEditorFunctionCallResults,
|
||||
addEditorFunctionCallResults,
|
||||
onSceneEventsModifiedOutsideEditor,
|
||||
i18n,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper square background="dark" style={styles.paper}>
|
||||
<div style={styles.chatContainer}>
|
||||
<AiRequestChat
|
||||
project={project || null}
|
||||
ref={aiRequestChatRef}
|
||||
aiRequest={selectedAiRequest}
|
||||
onStartNewAiRequest={startNewAiRequest}
|
||||
onSendMessage={onSendMessage}
|
||||
isSending={isSendingAiRequest(selectedAiRequestId)}
|
||||
lastSendError={getLastSendError(selectedAiRequestId)}
|
||||
quota={quota}
|
||||
increaseQuotaOffering={
|
||||
!hasValidSubscriptionPlan(subscription)
|
||||
? 'subscribe'
|
||||
: canUpgradeSubscription(subscription)
|
||||
? 'upgrade'
|
||||
: 'none'
|
||||
}
|
||||
onProcessFunctionCalls={onProcessFunctionCalls}
|
||||
editorFunctionCallResults={
|
||||
(selectedAiRequest &&
|
||||
getEditorFunctionCallResults(selectedAiRequest.id)) ||
|
||||
null
|
||||
}
|
||||
price={aiRequestPrice}
|
||||
availableCredits={availableCredits}
|
||||
onSendFeedback={onSendFeedback}
|
||||
hasOpenedProject={!!project}
|
||||
isAutoProcessingFunctionCalls={
|
||||
selectedAiRequest
|
||||
? isAutoProcessingFunctionCalls(selectedAiRequest.id)
|
||||
: false
|
||||
}
|
||||
setAutoProcessFunctionCalls={shouldAutoProcess => {
|
||||
if (!selectedAiRequest) return;
|
||||
setAutoProcessFunctionCalls(
|
||||
selectedAiRequest.id,
|
||||
shouldAutoProcess
|
||||
);
|
||||
}}
|
||||
i18n={i18n}
|
||||
editorCallbacks={editorCallbacks}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
<AskAiHistory
|
||||
open={isHistoryOpen}
|
||||
onClose={onCloseHistory}
|
||||
onSelectAiRequest={aiRequest => {
|
||||
// Immediately switch the UI and refresh in the background.
|
||||
updateAiRequest(aiRequest.id, aiRequest);
|
||||
setSelectedAiRequestId(aiRequest.id);
|
||||
refreshAiRequest(aiRequest.id);
|
||||
}}
|
||||
selectedAiRequestId={selectedAiRequestId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
),
|
||||
// Prevent any update to the editor if the editor is not active,
|
||||
// and so not visible to the user.
|
||||
(prevProps, nextProps) => prevProps.isActive || nextProps.isActive
|
||||
);
|
||||
|
||||
export const renderAskAiEditorContainer = (
|
||||
props: RenderEditorContainerPropsWithRef
|
||||
) => (
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<AskAiEditor
|
||||
ref={props.ref}
|
||||
i18n={i18n}
|
||||
project={props.project || null}
|
||||
resourceManagementProps={props.resourceManagementProps}
|
||||
fileMetadata={props.fileMetadata}
|
||||
storageProvider={props.storageProvider}
|
||||
setToolbar={props.setToolbar}
|
||||
isActive={props.isActive}
|
||||
onCreateEmptyProject={props.onCreateEmptyProject}
|
||||
onOpenLayout={props.onOpenLayout}
|
||||
onOpenEvents={props.onOpenEvents}
|
||||
onSceneEventsModifiedOutsideEditor={
|
||||
props.onSceneEventsModifiedOutsideEditor
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</I18n>
|
||||
);
|
@@ -2,25 +2,25 @@
|
||||
import * as React from 'react';
|
||||
import Drawer from '@material-ui/core/Drawer';
|
||||
import ButtonBase from '@material-ui/core/ButtonBase';
|
||||
import { Line, Column } from '../../../UI/Grid';
|
||||
import { ColumnStackLayout } from '../../../UI/Layout';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Line, Column } from '../UI/Grid';
|
||||
import { ColumnStackLayout } from '../UI/Layout';
|
||||
import Text from '../UI/Text';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
getAiRequests,
|
||||
type AiRequest,
|
||||
} from '../../../Utils/GDevelopServices/Generation';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import ScrollView from '../../../UI/ScrollView';
|
||||
import FlatButton from '../../../UI/FlatButton';
|
||||
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import EmptyMessage from '../../../UI/EmptyMessage';
|
||||
import CircularProgress from '../../../UI/CircularProgress';
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import Paper from '../UI/Paper';
|
||||
import ScrollView from '../UI/ScrollView';
|
||||
import FlatButton from '../UI/FlatButton';
|
||||
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import EmptyMessage from '../UI/EmptyMessage';
|
||||
import CircularProgress from '../UI/CircularProgress';
|
||||
import formatDate from 'date-fns/format';
|
||||
import DrawerTopBar from '../../../UI/DrawerTopBar';
|
||||
import PlaceholderError from '../../../UI/PlaceholderError';
|
||||
import { textEllipsisStyle } from '../../../UI/TextEllipsis';
|
||||
import DrawerTopBar from '../UI/DrawerTopBar';
|
||||
import PlaceholderError from '../UI/PlaceholderError';
|
||||
import { textEllipsisStyle } from '../UI/TextEllipsis';
|
||||
|
||||
type Props = {|
|
||||
open: boolean,
|
||||
@@ -61,8 +61,15 @@ const getFirstUserRequestText = (aiRequest: AiRequest): string => {
|
||||
if (!aiRequest.output || aiRequest.output.length === 0) return '';
|
||||
|
||||
// Find the first user message
|
||||
const userMessage = aiRequest.output.find(message => message.role === 'user');
|
||||
if (!userMessage) return '';
|
||||
const userMessage = aiRequest.output.find(
|
||||
message => message.type === 'message' && message.role === 'user'
|
||||
);
|
||||
if (
|
||||
!userMessage ||
|
||||
userMessage.type !== 'message' ||
|
||||
userMessage.role !== 'user'
|
||||
)
|
||||
return '';
|
||||
|
||||
// Extract text from user message content
|
||||
return userMessage.content
|
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import { ToolbarGroup } from '../../../UI/Toolbar';
|
||||
import AddIcon from '../../../UI/CustomSvgIcons/Add';
|
||||
import HistoryIcon from '../../../UI/CustomSvgIcons/History';
|
||||
import RaisedButton from '../../../UI/RaisedButton';
|
||||
import IconButton from '../../../UI/IconButton';
|
||||
import { ToolbarGroup } from '../UI/Toolbar';
|
||||
import AddIcon from '../UI/CustomSvgIcons/Add';
|
||||
import HistoryIcon from '../UI/CustomSvgIcons/History';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import IconButton from '../UI/IconButton';
|
||||
|
||||
type Props = {|
|
||||
onStartNewChat: () => void,
|
54
newIDE/app/src/AiGeneration/UseEnsureExtensionInstalled.js
Normal file
54
newIDE/app/src/AiGeneration/UseEnsureExtensionInstalled.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import { ExtensionStoreContext } from '../AssetStore/ExtensionStore/ExtensionStoreContext';
|
||||
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
|
||||
import { installExtension } from '../AssetStore/ExtensionStore/InstallExtension';
|
||||
|
||||
type EnsureExtensionInstalledOptions = {|
|
||||
extensionName: string,
|
||||
|};
|
||||
|
||||
export const useEnsureExtensionInstalled = ({
|
||||
project,
|
||||
i18n,
|
||||
}: {|
|
||||
project: ?gdProject,
|
||||
i18n: I18nType,
|
||||
|}) => {
|
||||
const { translatedExtensionShortHeadersByName } = React.useContext(
|
||||
ExtensionStoreContext
|
||||
);
|
||||
const eventsFunctionsExtensionsState = React.useContext(
|
||||
EventsFunctionsExtensionsContext
|
||||
);
|
||||
|
||||
return {
|
||||
ensureExtensionInstalled: React.useCallback(
|
||||
async ({ extensionName }: EnsureExtensionInstalledOptions) => {
|
||||
if (!project) return;
|
||||
if (project.getCurrentPlatform().isExtensionLoaded(extensionName))
|
||||
return;
|
||||
|
||||
const extensionShortHeader =
|
||||
translatedExtensionShortHeadersByName[extensionName];
|
||||
if (!extensionShortHeader) {
|
||||
throw new Error("Can't find extension with the required name.");
|
||||
}
|
||||
|
||||
await installExtension(
|
||||
i18n,
|
||||
project,
|
||||
eventsFunctionsExtensionsState,
|
||||
extensionShortHeader
|
||||
);
|
||||
},
|
||||
[
|
||||
eventsFunctionsExtensionsState,
|
||||
i18n,
|
||||
project,
|
||||
translatedExtensionShortHeadersByName,
|
||||
]
|
||||
),
|
||||
};
|
||||
};
|
107
newIDE/app/src/AiGeneration/UseGenerateEvents.js
Normal file
107
newIDE/app/src/AiGeneration/UseGenerateEvents.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
|
||||
import { retryIfFailed } from '../Utils/RetryIfFailed';
|
||||
import { delay } from '../Utils/Delay';
|
||||
import {
|
||||
getAiGeneratedEvent,
|
||||
createAiGeneratedEvent,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
|
||||
import { type EventsGenerationResult } from '../EditorFunctions';
|
||||
import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
export const useGenerateEvents = ({ project }: {| project: ?gdProject |}) => {
|
||||
const { profile, getAuthorizationHeader } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
|
||||
const generateEvents = React.useCallback(
|
||||
async ({
|
||||
sceneName,
|
||||
eventsDescription,
|
||||
extensionNamesList,
|
||||
objectsList,
|
||||
existingEventsAsText,
|
||||
placementHint,
|
||||
relatedAiRequestId,
|
||||
}: {|
|
||||
sceneName: string,
|
||||
eventsDescription: string,
|
||||
extensionNamesList: string,
|
||||
objectsList: string,
|
||||
existingEventsAsText: string,
|
||||
placementHint: string,
|
||||
relatedAiRequestId: string,
|
||||
|}): Promise<EventsGenerationResult> => {
|
||||
if (!project) throw new Error('No project is opened.');
|
||||
if (!profile) throw new Error('User should be authenticated.');
|
||||
|
||||
const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd);
|
||||
const projectSpecificExtensionsSummaryJson = JSON.stringify(
|
||||
simplifiedProjectBuilder.getProjectSpecificExtensionsSummary(project)
|
||||
);
|
||||
|
||||
const createResult = await retryIfFailed({ times: 2 }, () =>
|
||||
createAiGeneratedEvent(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
partialGameProjectJson: JSON.stringify(
|
||||
simplifiedProjectBuilder.getSimplifiedProject(project, {
|
||||
scopeToScene: sceneName,
|
||||
}),
|
||||
null,
|
||||
2
|
||||
),
|
||||
projectSpecificExtensionsSummaryJson,
|
||||
sceneName,
|
||||
eventsDescription,
|
||||
extensionNamesList,
|
||||
objectsList,
|
||||
existingEventsAsText,
|
||||
placementHint,
|
||||
relatedAiRequestId,
|
||||
})
|
||||
);
|
||||
|
||||
if (!createResult.creationSucceeded) {
|
||||
return {
|
||||
generationCompleted: false,
|
||||
errorMessage: createResult.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
let remainingAttempts = 50;
|
||||
let aiGeneratedEvent = createResult.aiGeneratedEvent;
|
||||
while (aiGeneratedEvent.status === 'working') {
|
||||
remainingAttempts--;
|
||||
await delay(1000);
|
||||
|
||||
try {
|
||||
aiGeneratedEvent = await getAiGeneratedEvent(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiGeneratedEventId: aiGeneratedEvent.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Error while checking status of AI generated event - continuing...',
|
||||
error
|
||||
);
|
||||
}
|
||||
if (remainingAttempts <= 0) {
|
||||
return {
|
||||
generationCompleted: false,
|
||||
errorMessage:
|
||||
'Event generation started but failed to complete in time.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { generationCompleted: true, aiGeneratedEvent };
|
||||
},
|
||||
[getAuthorizationHeader, project, profile]
|
||||
);
|
||||
|
||||
return { generateEvents };
|
||||
};
|
82
newIDE/app/src/AiGeneration/UseSearchAndInstallAsset.js
Normal file
82
newIDE/app/src/AiGeneration/UseSearchAndInstallAsset.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @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,
|
||||
}: {|
|
||||
project: gdProject | null,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
|}) => {
|
||||
const { profile, getAuthorizationHeader } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
const installAsset = useInstallAsset({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
});
|
||||
|
||||
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]
|
||||
),
|
||||
};
|
||||
};
|
@@ -159,15 +159,21 @@ const AssetPackInstallDialog = ({
|
||||
project,
|
||||
}
|
||||
);
|
||||
const shouldUpdateExtension =
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders.length >
|
||||
0 &&
|
||||
(await showExtensionUpdateConfirmation(
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders
|
||||
));
|
||||
const extensionUpdateAction =
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders
|
||||
.length === 0
|
||||
? 'skip'
|
||||
: await showExtensionUpdateConfirmation({
|
||||
project,
|
||||
outOfDateExtensionShortHeaders:
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders,
|
||||
});
|
||||
if (extensionUpdateAction === 'abort') {
|
||||
return;
|
||||
}
|
||||
await installRequiredExtensions({
|
||||
requiredExtensionInstallation,
|
||||
shouldUpdateExtension,
|
||||
shouldUpdateExtension: extensionUpdateAction === 'update',
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
});
|
||||
|
@@ -50,7 +50,6 @@ function AssetSwappingDialog({
|
||||
] = React.useState<boolean>(false);
|
||||
const installAsset = useInstallAsset({
|
||||
project,
|
||||
objectsContainer,
|
||||
resourceManagementProps,
|
||||
});
|
||||
const { showAlert } = useAlertDialog();
|
||||
@@ -73,7 +72,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');
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type BehaviorShortHeader } from '../../Utils/GDevelopServices/Extension';
|
||||
import { isCompatibleWithGDevelopVersion } from '../../Utils/Extension/ExtensionCompatibilityChecker.js';
|
||||
import ButtonBase from '@material-ui/core/ButtonBase';
|
||||
import Text from '../../UI/Text';
|
||||
import { Trans } from '@lingui/macro';
|
||||
@@ -10,12 +11,10 @@ import HighlightedText from '../../UI/Search/HighlightedText';
|
||||
import { type SearchMatch } from '../../UI/Search/UseSearchStructuredItem';
|
||||
import Chip from '../../UI/Chip';
|
||||
import { LineStackLayout } from '../../UI/Layout';
|
||||
import { type SearchableBehaviorMetadata } from './BehaviorStoreContext';
|
||||
import { UserPublicProfileChip } from '../../UI/User/UserPublicProfileChip';
|
||||
import Tooltip from '@material-ui/core/Tooltip';
|
||||
import CircledInfo from '../../UI/CustomSvgIcons/SmallCircledInfo';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import semverSatisfies from 'semver/functions/satisfies';
|
||||
import { getIDEVersion } from '../../Version';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
@@ -34,7 +33,7 @@ type Props = {|
|
||||
id?: string,
|
||||
objectType: string,
|
||||
objectBehaviorsTypes: Array<string>,
|
||||
behaviorShortHeader: BehaviorShortHeader | SearchableBehaviorMetadata,
|
||||
behaviorShortHeader: BehaviorShortHeader,
|
||||
matches: ?Array<SearchMatch>,
|
||||
onChoose: () => void,
|
||||
onShowDetails: () => void,
|
||||
@@ -68,11 +67,10 @@ export const BehaviorListItem = ({
|
||||
objectBehaviorsTypes.includes(requiredBehaviorType)
|
||||
);
|
||||
});
|
||||
const isEngineCompatible =
|
||||
!behaviorShortHeader.gdevelopVersion ||
|
||||
semverSatisfies(getIDEVersion(), behaviorShortHeader.gdevelopVersion, {
|
||||
includePrerelease: true,
|
||||
});
|
||||
const isEngineCompatible = isCompatibleWithGDevelopVersion(
|
||||
getIDEVersion(),
|
||||
behaviorShortHeader.gdevelopVersion
|
||||
);
|
||||
|
||||
// Report the height of the item once it's known.
|
||||
const containerRef = React.useRef<?HTMLDivElement>(null);
|
||||
|
@@ -22,26 +22,15 @@ const emptySearchText = '';
|
||||
const noExcludedTiers = new Set();
|
||||
const excludedCommunityTiers = new Set(['community']);
|
||||
|
||||
export type SearchableBehaviorMetadata = {|
|
||||
type: string,
|
||||
fullName: string,
|
||||
description: string,
|
||||
objectType: string,
|
||||
/**
|
||||
* All required behaviors including transitive ones.
|
||||
*/
|
||||
allRequiredBehaviorTypes: Array<string>,
|
||||
previewIconUrl: string,
|
||||
category: string,
|
||||
tags: string[],
|
||||
isDeprecated?: boolean,
|
||||
type TranslatedBehaviorShortHeader = {|
|
||||
...BehaviorShortHeader,
|
||||
englishFullName: string,
|
||||
englishDescription: string,
|
||||
|};
|
||||
|
||||
type BehaviorStoreState = {|
|
||||
filters: ?Filters,
|
||||
searchResults: ?Array<
|
||||
SearchResult<BehaviorShortHeader | SearchableBehaviorMetadata>
|
||||
>,
|
||||
searchResults: ?Array<SearchResult<BehaviorShortHeader>>,
|
||||
fetchBehaviors: () => void,
|
||||
error: ?Error,
|
||||
searchText: string,
|
||||
@@ -50,9 +39,11 @@ type BehaviorStoreState = {|
|
||||
chosenCategory: string,
|
||||
setChosenCategory: string => void,
|
||||
setInstalledBehaviorMetadataList: (
|
||||
installedBehaviorMetadataList: Array<SearchableBehaviorMetadata>
|
||||
installedBehaviorMetadataList: Array<BehaviorShortHeader>
|
||||
) => void,
|
||||
translatedBehaviorShortHeadersByType: { [name: string]: BehaviorShortHeader },
|
||||
translatedBehaviorShortHeadersByType: {
|
||||
[name: string]: TranslatedBehaviorShortHeader,
|
||||
},
|
||||
filtersState: FiltersState,
|
||||
|};
|
||||
|
||||
@@ -92,12 +83,12 @@ export const BehaviorStoreStateProvider = ({
|
||||
const [
|
||||
installedBehaviorMetadataList,
|
||||
setInstalledBehaviorMetadataList,
|
||||
] = React.useState<Array<SearchableBehaviorMetadata>>([]);
|
||||
] = React.useState<Array<BehaviorShortHeader>>([]);
|
||||
const [
|
||||
translatedBehaviorShortHeadersByType,
|
||||
setTranslatedBehaviorShortHeadersByType,
|
||||
] = React.useState<{
|
||||
[string]: BehaviorShortHeader,
|
||||
[string]: TranslatedBehaviorShortHeader,
|
||||
}>({});
|
||||
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
@@ -139,10 +130,12 @@ export const BehaviorStoreStateProvider = ({
|
||||
|
||||
const translatedBehaviorShortHeadersByType = {};
|
||||
behaviorShortHeaders.forEach(behaviorShortHeader => {
|
||||
const translatedBehaviorShortHeader: BehaviorShortHeader = {
|
||||
const translatedBehaviorShortHeader: TranslatedBehaviorShortHeader = {
|
||||
...behaviorShortHeader,
|
||||
fullName: i18n._(behaviorShortHeader.fullName),
|
||||
description: i18n._(behaviorShortHeader.description),
|
||||
englishFullName: behaviorShortHeader.fullName,
|
||||
englishDescription: behaviorShortHeader.description,
|
||||
};
|
||||
translatedBehaviorShortHeadersByType[
|
||||
behaviorShortHeader.type
|
||||
@@ -216,16 +209,76 @@ export const BehaviorStoreStateProvider = ({
|
||||
const allTranslatedBehaviors = React.useMemo(
|
||||
() => {
|
||||
const allTranslatedBehaviors: {
|
||||
[name: string]: BehaviorShortHeader | SearchableBehaviorMetadata,
|
||||
[name: string]: BehaviorShortHeader,
|
||||
} = {};
|
||||
for (const type in translatedBehaviorShortHeadersByType) {
|
||||
allTranslatedBehaviors[type] =
|
||||
translatedBehaviorShortHeadersByType[type];
|
||||
const behaviorShortHeader: any = {
|
||||
...translatedBehaviorShortHeadersByType[type],
|
||||
};
|
||||
delete behaviorShortHeader.englishFullName;
|
||||
delete behaviorShortHeader.englishDescription;
|
||||
allTranslatedBehaviors[type] = behaviorShortHeader;
|
||||
}
|
||||
for (const installedBehaviorMetadata of installedBehaviorMetadataList) {
|
||||
const repositoryBehaviorMetadata =
|
||||
translatedBehaviorShortHeadersByType[installedBehaviorMetadata.type];
|
||||
const behaviorMetadata = repositoryBehaviorMetadata
|
||||
? {
|
||||
// Attributes from the extension repository
|
||||
|
||||
// These attributes are important for the installation and update workflow.
|
||||
isInstalled: true,
|
||||
tier: repositoryBehaviorMetadata.tier,
|
||||
version: repositoryBehaviorMetadata.version,
|
||||
changelog: repositoryBehaviorMetadata.changelog,
|
||||
url: repositoryBehaviorMetadata.url,
|
||||
// It gives info about the extension that can be displayed to users.
|
||||
headerUrl: repositoryBehaviorMetadata.headerUrl,
|
||||
authorIds: repositoryBehaviorMetadata.authorIds,
|
||||
authors: repositoryBehaviorMetadata.authors,
|
||||
// It's empty and not used.
|
||||
extensionNamespace: repositoryBehaviorMetadata.extensionNamespace,
|
||||
|
||||
// Attributes from the installed extension
|
||||
|
||||
// New extension versions might require a different object.
|
||||
// It must not forbid users to attach the behavior since their
|
||||
// version allows it.
|
||||
objectType: installedBehaviorMetadata.objectType,
|
||||
allRequiredBehaviorTypes:
|
||||
installedBehaviorMetadata.allRequiredBehaviorTypes,
|
||||
// These ones are less important but its better to use the icon of
|
||||
// the installed extension since it's used everywhere in the editor.
|
||||
previewIconUrl: installedBehaviorMetadata.previewIconUrl,
|
||||
category: installedBehaviorMetadata.category,
|
||||
tags: installedBehaviorMetadata.tags,
|
||||
// Both metadata are supposed to have the same type, but the
|
||||
// installed ones are safer to use.
|
||||
// It reduces the risk of accessing an extension that doesn't
|
||||
// actually exist in the project.
|
||||
type: installedBehaviorMetadata.type,
|
||||
name: installedBehaviorMetadata.name,
|
||||
extensionName: installedBehaviorMetadata.extensionName,
|
||||
|
||||
// Attributes switching between both
|
||||
|
||||
// Translations may not be relevant for the installed version.
|
||||
// We use the translation only if the not translated texts match.
|
||||
fullName:
|
||||
installedBehaviorMetadata.fullName ===
|
||||
repositoryBehaviorMetadata.englishFullName
|
||||
? repositoryBehaviorMetadata.fullName
|
||||
: installedBehaviorMetadata.fullName,
|
||||
description:
|
||||
installedBehaviorMetadata.description ===
|
||||
repositoryBehaviorMetadata.englishDescription
|
||||
? repositoryBehaviorMetadata.description
|
||||
: installedBehaviorMetadata.description,
|
||||
}
|
||||
: installedBehaviorMetadata;
|
||||
allTranslatedBehaviors[
|
||||
installedBehaviorMetadata.type
|
||||
] = installedBehaviorMetadata;
|
||||
] = behaviorMetadata;
|
||||
}
|
||||
return allTranslatedBehaviors;
|
||||
},
|
||||
@@ -282,7 +335,7 @@ export const BehaviorStoreStateProvider = ({
|
||||
);
|
||||
|
||||
const searchResults: ?Array<
|
||||
SearchResult<BehaviorShortHeader | SearchableBehaviorMetadata>
|
||||
SearchResult<BehaviorShortHeader>
|
||||
> = useSearchStructuredItem(allTranslatedBehaviors, {
|
||||
searchText,
|
||||
chosenItemCategory: chosenCategory,
|
||||
|
@@ -1,7 +1,12 @@
|
||||
// @flow
|
||||
import { type I18n as I18nType } from '@lingui/core';
|
||||
import * as React from 'react';
|
||||
import semverGreaterThan from 'semver/functions/gt';
|
||||
import SearchBar from '../../UI/SearchBar';
|
||||
import {
|
||||
getBreakingChanges,
|
||||
isCompatibleWithGDevelopVersion,
|
||||
} from '../../Utils/Extension/ExtensionCompatibilityChecker.js';
|
||||
import { type BehaviorShortHeader } from '../../Utils/GDevelopServices/Extension';
|
||||
import { BehaviorStoreContext } from './BehaviorStoreContext';
|
||||
import { ListSearchResults } from '../../UI/Search/ListSearchResults';
|
||||
@@ -16,21 +21,31 @@ import PreferencesContext from '../../MainFrame/Preferences/PreferencesContext';
|
||||
import { ResponsiveLineStackLayout } from '../../UI/Layout';
|
||||
import SearchBarSelectField from '../../UI/SearchBarSelectField';
|
||||
import SelectOption from '../../UI/SelectOption';
|
||||
import { type SearchableBehaviorMetadata } from './BehaviorStoreContext';
|
||||
import ElementWithMenu from '../../UI/Menu/ElementWithMenu';
|
||||
import IconButton from '../../UI/IconButton';
|
||||
import ThreeDotsMenu from '../../UI/CustomSvgIcons/ThreeDotsMenu';
|
||||
import useAlertDialog from '../../UI/Alert/useAlertDialog';
|
||||
import ExtensionInstallDialog from '../ExtensionStore/ExtensionInstallDialog';
|
||||
import { getIDEVersion } from '../../Version';
|
||||
|
||||
export const useExtensionUpdateAlertDialog = () => {
|
||||
const { showConfirmation } = useAlertDialog();
|
||||
return async (): Promise<boolean> => {
|
||||
return async (
|
||||
project: gdProject,
|
||||
behaviorShortHeader: BehaviorShortHeader
|
||||
): Promise<boolean> => {
|
||||
return await showConfirmation({
|
||||
title: t`Extension update`,
|
||||
message: t`This behavior needs an extension update. You may have to do some adaptations to make sure your game still works.${'\n\n'}Do you want to update it now ?`,
|
||||
message:
|
||||
behaviorShortHeader.tier === 'reviewed'
|
||||
? // Reviewed extensions are closely watched
|
||||
// and any breaking change will be added to the extension metadata.
|
||||
t`This behavior can be updated with new features and fixes.${'\n\n'}Do you want to update it now ?`
|
||||
: // Community extensions are checked as much as possible
|
||||
// but we can't ensure every breaking changes will be added to the extension metadata.
|
||||
t`This behavior can be updated. You may have to do some adaptations to make sure your game still works.${'\n\n'}Do you want to update it now ?`,
|
||||
confirmButtonLabel: t`Update the extension`,
|
||||
dismissButtonLabel: t`Cancel`,
|
||||
dismissButtonLabel: t`Skip the update`,
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -40,15 +55,14 @@ type Props = {|
|
||||
project: gdProject,
|
||||
objectType: string,
|
||||
objectBehaviorsTypes: Array<string>,
|
||||
installedBehaviorMetadataList: Array<SearchableBehaviorMetadata>,
|
||||
deprecatedBehaviorMetadataList: Array<SearchableBehaviorMetadata>,
|
||||
installedBehaviorMetadataList: Array<BehaviorShortHeader>,
|
||||
deprecatedBehaviorMetadataList: Array<BehaviorShortHeader>,
|
||||
onInstall: (behaviorShortHeader: BehaviorShortHeader) => Promise<boolean>,
|
||||
onChoose: (behaviorType: string) => void,
|
||||
|};
|
||||
|
||||
const getBehaviorType = (
|
||||
behaviorShortHeader: BehaviorShortHeader | SearchableBehaviorMetadata
|
||||
) => behaviorShortHeader.type;
|
||||
const getBehaviorType = (behaviorShortHeader: BehaviorShortHeader) =>
|
||||
behaviorShortHeader.type;
|
||||
|
||||
export const BehaviorStore = ({
|
||||
isInstalling,
|
||||
@@ -119,9 +133,7 @@ export const BehaviorStore = ({
|
||||
);
|
||||
|
||||
const getExtensionsMatches = React.useCallback(
|
||||
(
|
||||
extensionShortHeader: BehaviorShortHeader | SearchableBehaviorMetadata
|
||||
): SearchMatch[] => {
|
||||
(extensionShortHeader: BehaviorShortHeader): SearchMatch[] => {
|
||||
if (!searchResults) return [];
|
||||
const extensionMatches = searchResults.find(
|
||||
result => result.item.type === extensionShortHeader.type
|
||||
@@ -138,20 +150,60 @@ export const BehaviorStore = ({
|
||||
const showExtensionUpdateConfirmation = useExtensionUpdateAlertDialog();
|
||||
|
||||
const installAndChoose = React.useCallback(
|
||||
async (
|
||||
behaviorShortHeader: BehaviorShortHeader | SearchableBehaviorMetadata
|
||||
) => {
|
||||
async (behaviorShortHeader: BehaviorShortHeader) => {
|
||||
if (behaviorShortHeader.tier === 'installed') {
|
||||
// The extension is not in the repository.
|
||||
// It's either built-in or user made.
|
||||
// It can't be updated.
|
||||
onChoose(behaviorShortHeader.type);
|
||||
return;
|
||||
}
|
||||
const isExtensionAlreadyInstalled =
|
||||
behaviorShortHeader.extensionName &&
|
||||
project.hasEventsFunctionsExtensionNamed(
|
||||
behaviorShortHeader.extensionName
|
||||
);
|
||||
if (isExtensionAlreadyInstalled) {
|
||||
const shouldUpdateExtension = await showExtensionUpdateConfirmation();
|
||||
const installedVersion = project
|
||||
.getEventsFunctionsExtension(behaviorShortHeader.extensionName)
|
||||
.getVersion();
|
||||
// repository version <= installed version
|
||||
if (!semverGreaterThan(behaviorShortHeader.version, installedVersion)) {
|
||||
// The extension is already up to date.
|
||||
onChoose(behaviorShortHeader.type);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isCompatibleWithGDevelopVersion(
|
||||
getIDEVersion(),
|
||||
behaviorShortHeader.gdevelopVersion
|
||||
)
|
||||
) {
|
||||
// Don't suggest to update the extension if the editor can't understand it.
|
||||
onChoose(behaviorShortHeader.type);
|
||||
return;
|
||||
}
|
||||
const breakingChanges = getBreakingChanges(
|
||||
installedVersion,
|
||||
behaviorShortHeader
|
||||
);
|
||||
if (breakingChanges && breakingChanges.length > 0) {
|
||||
// Don't suggest to update the extension if it would break the project.
|
||||
onChoose(behaviorShortHeader.type);
|
||||
return;
|
||||
}
|
||||
const shouldUpdateExtension = await showExtensionUpdateConfirmation(
|
||||
project,
|
||||
behaviorShortHeader
|
||||
);
|
||||
if (!shouldUpdateExtension) {
|
||||
onChoose(behaviorShortHeader.type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Behaviors from the store that are not compatible with the editor are
|
||||
// greyed out in the list and can't be chosen by users.
|
||||
// No need to check `isCompatibleWithGDevelopVersion`.
|
||||
|
||||
if (behaviorShortHeader.url) {
|
||||
sendExtensionAddedToProject(behaviorShortHeader.name);
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import * as React from 'react';
|
||||
import { type ExampleShortHeader } from '../../Utils/GDevelopServices/Example';
|
||||
import { isCompatibleWithGDevelopVersion } from '../../Utils/GDevelopServices/Asset';
|
||||
import { isCompatibleWithGDevelopVersion } from '../../Utils/Extension/ExtensionCompatibilityChecker.js';
|
||||
import { MarkdownText } from '../../UI/MarkdownText';
|
||||
import Text from '../../UI/Text';
|
||||
import AlertMessage from '../../UI/AlertMessage';
|
||||
|
@@ -8,8 +8,13 @@ import {
|
||||
type ExtensionHeader,
|
||||
type BehaviorShortHeader,
|
||||
getExtensionHeader,
|
||||
isCompatibleWithExtension,
|
||||
} from '../../Utils/GDevelopServices/Extension';
|
||||
import {
|
||||
getBreakingChanges,
|
||||
formatOldBreakingChanges,
|
||||
formatBreakingChanges,
|
||||
isCompatibleWithGDevelopVersion,
|
||||
} from '../../Utils/Extension/ExtensionCompatibilityChecker.js';
|
||||
import LeftLoader from '../../UI/LeftLoader';
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
import PlaceholderError from '../../UI/PlaceholderError';
|
||||
@@ -26,6 +31,7 @@ import Window from '../../Utils/Window';
|
||||
import { useExtensionUpdate } from './UseExtensionUpdates';
|
||||
import HelpButton from '../../UI/HelpButton';
|
||||
import useAlertDialog from '../../UI/Alert/useAlertDialog';
|
||||
import { Accordion, AccordionHeader, AccordionBody } from '../../UI/Accordion';
|
||||
|
||||
export const useOutOfDateAlertDialog = () => {
|
||||
const { showConfirmation } = useAlertDialog();
|
||||
@@ -84,6 +90,21 @@ const ExtensionInstallDialog = ({
|
||||
? installedExtension.getOriginName() === 'gdevelop-extension-store'
|
||||
: false;
|
||||
|
||||
const newBreakingChangesText = installedExtension
|
||||
? formatBreakingChanges(
|
||||
getBreakingChanges(
|
||||
installedExtension.getVersion(),
|
||||
extensionShortHeader
|
||||
)
|
||||
)
|
||||
: null;
|
||||
const oldBreakingChangesText = installedExtension
|
||||
? formatOldBreakingChanges(
|
||||
installedExtension.getVersion(),
|
||||
extensionShortHeader
|
||||
)
|
||||
: null;
|
||||
|
||||
const extensionUpdate = useExtensionUpdate(project, extensionShortHeader);
|
||||
|
||||
const [error, setError] = React.useState<?Error>(null);
|
||||
@@ -109,9 +130,9 @@ const ExtensionInstallDialog = ({
|
||||
|
||||
React.useEffect(() => loadExtensionheader(), [loadExtensionheader]);
|
||||
|
||||
const isCompatible = isCompatibleWithExtension(
|
||||
const isCompatible = isCompatibleWithGDevelopVersion(
|
||||
getIDEVersion(),
|
||||
extensionShortHeader
|
||||
extensionShortHeader.gdevelopVersion
|
||||
);
|
||||
|
||||
const canInstallExtension = !isInstalling && isCompatible;
|
||||
@@ -311,6 +332,26 @@ const ExtensionInstallDialog = ({
|
||||
</Trans>
|
||||
</PlaceholderError>
|
||||
)}
|
||||
{newBreakingChangesText && (
|
||||
<>
|
||||
<Text size="sub-title">
|
||||
<Trans>Breaking changes</Trans>
|
||||
</Text>
|
||||
<MarkdownText source={newBreakingChangesText} isStandaloneText />
|
||||
</>
|
||||
)}
|
||||
{oldBreakingChangesText && (
|
||||
<Accordion noMargin>
|
||||
<AccordionHeader noMargin>
|
||||
<Text size="sub-title">
|
||||
<Trans>Previous breaking changes (no longer relevant)</Trans>
|
||||
</Text>
|
||||
</AccordionHeader>
|
||||
<AccordionBody disableGutters>
|
||||
<MarkdownText source={oldBreakingChangesText} isStandaloneText />
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
</Dialog>
|
||||
);
|
||||
|
@@ -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,
|
||||
|
@@ -4,7 +4,6 @@ import {
|
||||
isPixelArt,
|
||||
isPublicAssetResourceUrl,
|
||||
extractDecodedFilenameWithExtensionFromPublicAssetResourceUrl,
|
||||
isCompatibleWithGDevelopVersion,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import { getIDEVersion } from '../Version';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
@@ -23,37 +22,13 @@ import { mapVector } from '../Utils/MapFor';
|
||||
import { toNewGdMapStringString } from '../Utils/MapStringString';
|
||||
import { getInsertionParentAndPositionFromSelection } from '../Utils/ObjectFolders';
|
||||
import { allResourceKindsAndMetadata } from '../ResourcesList/ResourceSource';
|
||||
import {
|
||||
getBreakingChanges,
|
||||
isCompatibleWithGDevelopVersion,
|
||||
} from '../Utils/Extension/ExtensionCompatibilityChecker.js';
|
||||
|
||||
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.
|
||||
@@ -153,6 +128,7 @@ export type InstallAssetArgs = {|
|
||||
project: gdProject,
|
||||
objectsContainer: gdObjectsContainer,
|
||||
targetObjectFolderOrObject?: ?gdObjectFolderOrObject,
|
||||
requestedObjectName?: string,
|
||||
|};
|
||||
|
||||
const findVariant = (
|
||||
@@ -177,6 +153,7 @@ export const addAssetToProject = async ({
|
||||
project,
|
||||
objectsContainer,
|
||||
targetObjectFolderOrObject,
|
||||
requestedObjectName,
|
||||
}: InstallAssetArgs): Promise<InstallAssetOutput> => {
|
||||
const objectNewNames = {};
|
||||
const resourceNewNames = {};
|
||||
@@ -269,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)
|
||||
);
|
||||
@@ -391,7 +370,10 @@ export type RequiredExtensionInstallation = {|
|
||||
requiredExtensionShortHeaders: Array<ExtensionShortHeader>,
|
||||
missingExtensionShortHeaders: Array<ExtensionShortHeader>,
|
||||
outOfDateExtensionShortHeaders: Array<ExtensionShortHeader>,
|
||||
breakingChangesExtensionShortHeaders: Array<ExtensionShortHeader>,
|
||||
incompatibleWithIdeExtensionShortHeaders: Array<ExtensionShortHeader>,
|
||||
safeToUpdateExtensions: Array<ExtensionShortHeader>,
|
||||
isGDevelopUpdateNeeded: boolean,
|
||||
|};
|
||||
|
||||
export type InstallRequiredExtensionsArgs = {|
|
||||
@@ -509,7 +491,10 @@ export const checkRequiredExtensionsUpdate = async ({
|
||||
requiredExtensionShortHeaders: [],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -534,24 +519,15 @@ export const checkRequiredExtensionsUpdate = async ({
|
||||
}
|
||||
);
|
||||
|
||||
const compatibleWithIdeExtensionShortHeaders: Array<ExtensionShortHeader> = [];
|
||||
const incompatibleWithIdeExtensionShortHeaders: Array<ExtensionShortHeader> = [];
|
||||
for (const requiredExtensionShortHeader of requiredExtensionShortHeaders) {
|
||||
if (
|
||||
isCompatibleWithGDevelopVersion(
|
||||
const incompatibleWithIdeExtensionShortHeaders = requiredExtensionShortHeaders.filter(
|
||||
requiredExtensionShortHeader =>
|
||||
!isCompatibleWithGDevelopVersion(
|
||||
getIDEVersion(),
|
||||
requiredExtensionShortHeader.gdevelopVersion
|
||||
)
|
||||
) {
|
||||
compatibleWithIdeExtensionShortHeaders.push(requiredExtensionShortHeader);
|
||||
} else {
|
||||
incompatibleWithIdeExtensionShortHeaders.push(
|
||||
requiredExtensionShortHeader
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const outOfDateExtensionShortHeaders = compatibleWithIdeExtensionShortHeaders.filter(
|
||||
const outOfDateExtensionShortHeaders = requiredExtensionShortHeaders.filter(
|
||||
requiredExtensionShortHeader =>
|
||||
project.hasEventsFunctionsExtensionNamed(
|
||||
requiredExtensionShortHeader.name
|
||||
@@ -561,16 +537,43 @@ export const checkRequiredExtensionsUpdate = async ({
|
||||
.getVersion() !== requiredExtensionShortHeader.version
|
||||
);
|
||||
|
||||
const breakingChangesExtensionShortHeaders = outOfDateExtensionShortHeaders.filter(
|
||||
requiredExtensionShortHeader =>
|
||||
project.hasEventsFunctionsExtensionNamed(
|
||||
requiredExtensionShortHeader.name
|
||||
) &&
|
||||
getBreakingChanges(
|
||||
project
|
||||
.getEventsFunctionsExtension(requiredExtensionShortHeader.name)
|
||||
.getVersion(),
|
||||
requiredExtensionShortHeader
|
||||
).length > 0
|
||||
);
|
||||
|
||||
const missingExtensionShortHeaders = filterMissingExtensions(
|
||||
gd,
|
||||
compatibleWithIdeExtensionShortHeaders
|
||||
requiredExtensionShortHeaders
|
||||
);
|
||||
|
||||
const safeToUpdateExtensions = outOfDateExtensionShortHeaders.filter(
|
||||
extension =>
|
||||
!incompatibleWithIdeExtensionShortHeaders.includes(extension) &&
|
||||
!breakingChangesExtensionShortHeaders.includes(extension)
|
||||
);
|
||||
|
||||
// Overridden by `checkRequiredExtensionsUpdateForAssets`
|
||||
const isGDevelopUpdateNeeded = incompatibleWithIdeExtensionShortHeaders.some(
|
||||
extension => missingExtensionShortHeaders.includes(extension)
|
||||
);
|
||||
|
||||
return {
|
||||
requiredExtensionShortHeaders,
|
||||
missingExtensionShortHeaders,
|
||||
outOfDateExtensionShortHeaders,
|
||||
breakingChangesExtensionShortHeaders,
|
||||
incompatibleWithIdeExtensionShortHeaders,
|
||||
safeToUpdateExtensions,
|
||||
isGDevelopUpdateNeeded,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -598,7 +601,17 @@ export const checkRequiredExtensionsUpdateForAssets = async ({
|
||||
});
|
||||
});
|
||||
|
||||
return checkRequiredExtensionsUpdate({ requiredExtensions, project });
|
||||
const requiredExtensionsUpdate = await checkRequiredExtensionsUpdate({
|
||||
requiredExtensions,
|
||||
project,
|
||||
});
|
||||
// Even if the asset may work with already installed extensions,
|
||||
// we don't risk it since the asset may use the new features of the extension.
|
||||
requiredExtensionsUpdate.isGDevelopUpdateNeeded =
|
||||
requiredExtensionsUpdate.isGDevelopUpdateNeeded ||
|
||||
requiredExtensionsUpdate.incompatibleWithIdeExtensionShortHeaders.length >
|
||||
0;
|
||||
return requiredExtensionsUpdate;
|
||||
};
|
||||
|
||||
export const complyVariantsToEventsBasedObjectOf = (
|
||||
|
@@ -4,8 +4,8 @@ import {
|
||||
addSerializedExtensionsToProject,
|
||||
getRequiredExtensionsFromAsset,
|
||||
installRequiredExtensions,
|
||||
sanitizeObjectName,
|
||||
installPublicAsset,
|
||||
checkRequiredExtensionsUpdate,
|
||||
checkRequiredExtensionsUpdateForAssets,
|
||||
} from './InstallAsset';
|
||||
import { makeTestProject } from '../fixtures/TestProject';
|
||||
@@ -16,10 +16,13 @@ import {
|
||||
fakeAssetWithUnknownExtension1,
|
||||
fakeAssetWithFlashExtensionDependency1,
|
||||
flashExtensionShortHeader,
|
||||
incompatibleFlashExtensionShortHeader,
|
||||
fireBulletExtensionShortHeader,
|
||||
fakeAssetWithCustomObject,
|
||||
buttonV1ExtensionShortHeader,
|
||||
buttonV2ExtensionShortHeader,
|
||||
breakingButtonV3ExtensionShortHeader,
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
} from '../fixtures/GDevelopServicesTestData';
|
||||
import { makeTestExtensions } from '../fixtures/TestExtensions';
|
||||
import {
|
||||
@@ -28,7 +31,6 @@ import {
|
||||
type ExtensionShortHeader,
|
||||
} from '../Utils/GDevelopServices/Extension';
|
||||
import * as Asset from '../Utils/GDevelopServices/Asset';
|
||||
//import { useFetchAssets } from './NewObjectDialog';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
@@ -40,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);
|
||||
@@ -394,6 +383,258 @@ describe('InstallAsset', () => {
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('checkRequiredExtensionsUpdate', () => {
|
||||
it('can find an extension to install', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Flash')).toBe(false);
|
||||
|
||||
// The extension is in the registry.
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [fireBulletExtensionShortHeader, flashExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: flashExtensionShortHeader.name,
|
||||
extensionVersion: flashExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [flashExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [flashExtensionShortHeader],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an up to date extension from the project', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Button')).toBe(true);
|
||||
|
||||
// The extension is in the registry.
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [buttonV1ExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: buttonV1ExtensionShortHeader.name,
|
||||
extensionVersion: buttonV1ExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [buttonV1ExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to update', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Button')).toBe(true);
|
||||
|
||||
// The extension is in the registry.
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [buttonV2ExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: buttonV2ExtensionShortHeader.name,
|
||||
extensionVersion: buttonV2ExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [buttonV2ExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [buttonV2ExtensionShortHeader],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [buttonV2ExtensionShortHeader],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to update with breaking changes', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Button')).toBe(true);
|
||||
|
||||
// The extension is in the registry.
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [breakingButtonV3ExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: breakingButtonV3ExtensionShortHeader.name,
|
||||
extensionVersion: breakingButtonV3ExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [breakingButtonV3ExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [breakingButtonV3ExtensionShortHeader],
|
||||
breakingChangesExtensionShortHeaders: [
|
||||
breakingButtonV3ExtensionShortHeader,
|
||||
],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to update incompatible with the editor', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Button')).toBe(true);
|
||||
|
||||
// The extension is in the registry.
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [incompatibleButtonV4ExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: incompatibleButtonV4ExtensionShortHeader.name,
|
||||
extensionVersion:
|
||||
incompatibleButtonV4ExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to install incompatible with the editor', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Flash')).toBe(false);
|
||||
|
||||
// The extension is in the registry.
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [incompatibleFlashExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: incompatibleFlashExtensionShortHeader.name,
|
||||
extensionVersion: incompatibleFlashExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [incompatibleFlashExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [incompatibleFlashExtensionShortHeader],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [
|
||||
incompatibleFlashExtensionShortHeader,
|
||||
],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('errors if an extension is not found in the registry', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [flashExtensionShortHeader, fireBulletExtensionShortHeader],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: 'UnknownExtension',
|
||||
extensionVersion: '1.0.0',
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: 'Unable to find extension UnknownExtension in the registry.',
|
||||
});
|
||||
});
|
||||
|
||||
it("errors if the registry can't be loaded ", async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => {
|
||||
throw new Error('Fake error');
|
||||
});
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdate({
|
||||
requiredExtensions: [
|
||||
{
|
||||
extensionName: flashExtensionShortHeader.name,
|
||||
extensionVersion: flashExtensionShortHeader.version,
|
||||
},
|
||||
],
|
||||
project,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
message: 'Fake error',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRequiredExtensionsUpdateForAssets', () => {
|
||||
it('can find an extension to install', async () => {
|
||||
makeTestExtensions(gd);
|
||||
@@ -423,7 +664,10 @@ describe('InstallAsset', () => {
|
||||
requiredExtensionShortHeaders: [flashExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [flashExtensionShortHeader],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,7 +701,10 @@ describe('InstallAsset', () => {
|
||||
requiredExtensionShortHeaders: [buttonV1ExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -491,7 +738,132 @@ describe('InstallAsset', () => {
|
||||
requiredExtensionShortHeaders: [buttonV2ExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [buttonV2ExtensionShortHeader],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [buttonV2ExtensionShortHeader],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to update with breaking changes', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Button')).toBe(true);
|
||||
|
||||
// Get an asset that uses an extension...
|
||||
mockFn(Asset.getPublicAsset).mockImplementationOnce(
|
||||
() => fakeAssetWithCustomObject
|
||||
);
|
||||
|
||||
// ...and this extension is in the registry
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [
|
||||
flashExtensionShortHeader,
|
||||
fireBulletExtensionShortHeader,
|
||||
// The project contains the 1.0.0 of this extension.
|
||||
breakingButtonV3ExtensionShortHeader,
|
||||
],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdateForAssets({
|
||||
assets: [fakeAssetWithCustomObject, fakeAssetWithCustomObject],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [breakingButtonV3ExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [breakingButtonV3ExtensionShortHeader],
|
||||
breakingChangesExtensionShortHeaders: [
|
||||
breakingButtonV3ExtensionShortHeader,
|
||||
],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to update incompatible with the editor', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Button')).toBe(true);
|
||||
|
||||
// Get an asset that uses an extension...
|
||||
mockFn(Asset.getPublicAsset).mockImplementationOnce(
|
||||
() => fakeAssetWithCustomObject
|
||||
);
|
||||
|
||||
// ...and this extension is in the registry
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [
|
||||
flashExtensionShortHeader,
|
||||
fireBulletExtensionShortHeader,
|
||||
// The project contains the 1.0.0 of this extension.
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdateForAssets({
|
||||
assets: [fakeAssetWithCustomObject, fakeAssetWithCustomObject],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [
|
||||
incompatibleButtonV4ExtensionShortHeader,
|
||||
],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('can find an extension to install incompatible with the editor', async () => {
|
||||
makeTestExtensions(gd);
|
||||
const { project } = makeTestProject(gd);
|
||||
expect(project.hasEventsFunctionsExtensionNamed('Flash')).toBe(false);
|
||||
|
||||
// Get an asset that uses an extension...
|
||||
mockFn(Asset.getPublicAsset).mockImplementationOnce(
|
||||
() => fakeAssetWithFlashExtensionDependency1
|
||||
);
|
||||
|
||||
// ...and this extension is in the registry
|
||||
mockFn(getExtensionsRegistry).mockImplementationOnce(() => ({
|
||||
version: '1.0.0',
|
||||
headers: [
|
||||
incompatibleFlashExtensionShortHeader,
|
||||
fireBulletExtensionShortHeader,
|
||||
],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
checkRequiredExtensionsUpdateForAssets({
|
||||
assets: [
|
||||
fakeAssetWithFlashExtensionDependency1,
|
||||
fakeAssetWithFlashExtensionDependency1,
|
||||
],
|
||||
project,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
requiredExtensionShortHeaders: [incompatibleFlashExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [incompatibleFlashExtensionShortHeader],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [
|
||||
incompatibleFlashExtensionShortHeader,
|
||||
],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -654,7 +1026,10 @@ describe('InstallAsset', () => {
|
||||
},
|
||||
],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
},
|
||||
shouldUpdateExtension: true,
|
||||
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
|
||||
@@ -686,7 +1061,10 @@ describe('InstallAsset', () => {
|
||||
requiredExtensionShortHeaders: [flashExtensionShortHeader],
|
||||
missingExtensionShortHeaders: [flashExtensionShortHeader],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
},
|
||||
shouldUpdateExtension: true,
|
||||
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
|
||||
@@ -723,7 +1101,10 @@ describe('InstallAsset', () => {
|
||||
],
|
||||
missingExtensionShortHeaders: [],
|
||||
outOfDateExtensionShortHeaders: [],
|
||||
breakingChangesExtensionShortHeaders: [],
|
||||
incompatibleWithIdeExtensionShortHeaders: [],
|
||||
safeToUpdateExtensions: [],
|
||||
isGDevelopUpdateNeeded: false,
|
||||
},
|
||||
shouldUpdateExtension: true,
|
||||
eventsFunctionsExtensionsState: mockEventsFunctionsExtensionsState,
|
||||
|
@@ -29,6 +29,11 @@ import {
|
||||
isPrivateAsset,
|
||||
} from '../Utils/GDevelopServices/Asset';
|
||||
import { type ExtensionShortHeader } from '../Utils/GDevelopServices/Extension';
|
||||
import {
|
||||
getBreakingChanges,
|
||||
formatExtensionsBreakingChanges,
|
||||
type ExtensionChange,
|
||||
} from '../Utils/Extension/ExtensionCompatibilityChecker.js';
|
||||
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
|
||||
import Window from '../Utils/Window';
|
||||
import PrivateAssetsAuthorizationContext from './PrivateAssets/PrivateAssetsAuthorizationContext';
|
||||
@@ -48,20 +53,64 @@ import { AssetStoreNavigatorContext } from './AssetStoreNavigator';
|
||||
const isDev = Window.isDev();
|
||||
|
||||
export const useExtensionUpdateAlertDialog = () => {
|
||||
const { showConfirmation } = useAlertDialog();
|
||||
return async (
|
||||
outOfDateExtensionShortHeaders: Array<ExtensionShortHeader>
|
||||
): Promise<boolean> => {
|
||||
return await showConfirmation({
|
||||
title: t`Extension update`,
|
||||
message: t`Before installing this asset, it's strongly recommended to update these extensions${'\n\n - ' +
|
||||
outOfDateExtensionShortHeaders
|
||||
.map(extension => extension.fullName)
|
||||
.join('\n\n - ') +
|
||||
'\n\n'}Do you want to update it now ?`,
|
||||
confirmButtonLabel: t`Update the extension`,
|
||||
dismissButtonLabel: t`Skip the update`,
|
||||
});
|
||||
const { showConfirmation, showDeleteConfirmation } = useAlertDialog();
|
||||
return async ({
|
||||
project,
|
||||
outOfDateExtensionShortHeaders,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
outOfDateExtensionShortHeaders: Array<ExtensionShortHeader>,
|
||||
|}): Promise<string> => {
|
||||
const breakingChanges = new Map<
|
||||
ExtensionShortHeader,
|
||||
Array<ExtensionChange>
|
||||
>();
|
||||
for (const extension of outOfDateExtensionShortHeaders) {
|
||||
if (!project.hasEventsFunctionsExtensionNamed(extension.name)) {
|
||||
continue;
|
||||
}
|
||||
const installedVersion = project
|
||||
.getEventsFunctionsExtension(extension.name)
|
||||
.getVersion();
|
||||
const extensionBreakingChanges = getBreakingChanges(
|
||||
installedVersion,
|
||||
extension
|
||||
);
|
||||
if (extensionBreakingChanges.length > 0) {
|
||||
breakingChanges.set(extension, extensionBreakingChanges);
|
||||
}
|
||||
}
|
||||
const notBreakingExtensions = outOfDateExtensionShortHeaders.filter(
|
||||
extension => !breakingChanges.has(extension)
|
||||
);
|
||||
if (breakingChanges.size > 0) {
|
||||
// Extensions without breaking changes are not listed since it would make
|
||||
// the message more confusing.
|
||||
return (await showDeleteConfirmation({
|
||||
title: t`Breaking changes`,
|
||||
message: t`This asset requires updates to extensions that have breaking changes${'\n\n' +
|
||||
formatExtensionsBreakingChanges(breakingChanges) +
|
||||
'\n'}Do you want to update them now ?`,
|
||||
confirmButtonLabel: t`Update the extension`,
|
||||
dismissButtonLabel: t`Abort`,
|
||||
}))
|
||||
? 'update'
|
||||
: // Avoid to install assets which wouldn't work with the installed version.
|
||||
'abort';
|
||||
} else {
|
||||
return (await showConfirmation({
|
||||
title: t`Extension update`,
|
||||
message: t`Before installing this asset, it's strongly recommended to update these extensions${'\n\n - ' +
|
||||
notBreakingExtensions
|
||||
.map(extension => extension.fullName)
|
||||
.join('\n - ') +
|
||||
'\n\n'}Do you want to update them now ?`,
|
||||
confirmButtonLabel: t`Update the extension`,
|
||||
dismissButtonLabel: t`Skip the update`,
|
||||
}))
|
||||
? 'update'
|
||||
: 'skip';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -123,12 +172,10 @@ export const useFetchAssets = () => {
|
||||
|
||||
export const useInstallAsset = ({
|
||||
project,
|
||||
objectsContainer,
|
||||
targetObjectFolderOrObjectWithContext,
|
||||
resourceManagementProps,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
objectsContainer: gdObjectsContainer,
|
||||
project: gdProject | null,
|
||||
targetObjectFolderOrObjectWithContext?: ?ObjectFolderOrObjectWithContext,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
|}) => {
|
||||
@@ -147,9 +194,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;
|
||||
@@ -162,25 +218,28 @@ export const useInstallAsset = ({
|
||||
project,
|
||||
}
|
||||
);
|
||||
if (
|
||||
requiredExtensionInstallation.incompatibleWithIdeExtensionShortHeaders
|
||||
.length > 0
|
||||
) {
|
||||
if (requiredExtensionInstallation.isGDevelopUpdateNeeded) {
|
||||
showAlert({
|
||||
title: t`Could not install the asset`,
|
||||
message: t`Please upgrade the editor to the latest version.`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const shouldUpdateExtension =
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders.length >
|
||||
0 &&
|
||||
(await showExtensionUpdateConfirmation(
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders
|
||||
));
|
||||
const extensionUpdateAction =
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders.length ===
|
||||
0
|
||||
? 'skip'
|
||||
: await showExtensionUpdateConfirmation({
|
||||
project,
|
||||
outOfDateExtensionShortHeaders:
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders,
|
||||
});
|
||||
if (extensionUpdateAction === 'abort') {
|
||||
return null;
|
||||
}
|
||||
await installRequiredExtensions({
|
||||
requiredExtensionInstallation,
|
||||
shouldUpdateExtension,
|
||||
shouldUpdateExtension: extensionUpdateAction === 'update',
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
});
|
||||
@@ -190,6 +249,7 @@ export const useInstallAsset = ({
|
||||
asset,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject:
|
||||
targetObjectFolderOrObjectWithContext &&
|
||||
!targetObjectFolderOrObjectWithContext.global
|
||||
@@ -200,6 +260,7 @@ export const useInstallAsset = ({
|
||||
asset,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject:
|
||||
targetObjectFolderOrObjectWithContext &&
|
||||
!targetObjectFolderOrObjectWithContext.global
|
||||
@@ -315,7 +376,6 @@ function NewObjectDialog({
|
||||
const showExtensionUpdateConfirmation = useExtensionUpdateAlertDialog();
|
||||
const installAsset = useInstallAsset({
|
||||
project,
|
||||
objectsContainer,
|
||||
resourceManagementProps,
|
||||
targetObjectFolderOrObjectWithContext,
|
||||
});
|
||||
@@ -325,13 +385,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(
|
||||
@@ -346,25 +409,31 @@ function NewObjectDialog({
|
||||
project,
|
||||
}
|
||||
);
|
||||
if (
|
||||
requiredExtensionInstallation.incompatibleWithIdeExtensionShortHeaders
|
||||
.length > 0
|
||||
) {
|
||||
if (requiredExtensionInstallation.isGDevelopUpdateNeeded) {
|
||||
showAlert({
|
||||
title: t`Could not install the asset`,
|
||||
title: t`Could not install required extensions`,
|
||||
message: t`Please upgrade the editor to the latest version.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const shouldUpdateExtension =
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders.length >
|
||||
0 &&
|
||||
(await showExtensionUpdateConfirmation(
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders
|
||||
));
|
||||
// Users must be able to create an object from scratch without being
|
||||
// forced to update extensions that may break their projects.
|
||||
const safeToUpdateExtensions =
|
||||
requiredExtensionInstallation.safeToUpdateExtensions;
|
||||
const extensionUpdateAction =
|
||||
requiredExtensionInstallation.outOfDateExtensionShortHeaders
|
||||
.length === 0
|
||||
? 'skip'
|
||||
: (await showExtensionUpdateConfirmation({
|
||||
project,
|
||||
outOfDateExtensionShortHeaders: safeToUpdateExtensions,
|
||||
})) === 'update';
|
||||
if (extensionUpdateAction === 'abort') {
|
||||
return;
|
||||
}
|
||||
await installRequiredExtensions({
|
||||
requiredExtensionInstallation,
|
||||
shouldUpdateExtension,
|
||||
shouldUpdateExtension: extensionUpdateAction === 'update',
|
||||
eventsFunctionsExtensionsState,
|
||||
project,
|
||||
});
|
||||
|
@@ -114,6 +114,7 @@ const PrivateAssetsAuthorizationProvider = ({ children }: Props) => {
|
||||
asset,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject,
|
||||
}: InstallAssetArgs): Promise<?InstallAssetOutput> => {
|
||||
if (!profile) {
|
||||
@@ -134,6 +135,7 @@ const PrivateAssetsAuthorizationProvider = ({ children }: Props) => {
|
||||
asset: assetWithAuthorizedResourceUrls,
|
||||
project,
|
||||
objectsContainer,
|
||||
requestedObjectName,
|
||||
targetObjectFolderOrObject,
|
||||
});
|
||||
};
|
||||
|
@@ -74,3 +74,12 @@ export const filterEnumeratedBehaviorMetadata = (
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const isBehaviorDefaultCapability = (
|
||||
behaviorMetadata: gdBehaviorMetadata
|
||||
) => {
|
||||
return (
|
||||
behaviorMetadata.getName().includes('Capability') ||
|
||||
behaviorMetadata.getName() === 'Scene3D::Base3DBehavior'
|
||||
);
|
||||
};
|
||||
|
@@ -11,7 +11,6 @@ import { showMessageBox } from '../UI/Messages/MessageBox';
|
||||
import { getDeprecatedBehaviorsInformation } from '../Hints';
|
||||
import { enumerateBehaviorsMetadata } from './EnumerateBehaviorsMetadata';
|
||||
import { BehaviorStore } from '../AssetStore/BehaviorStore';
|
||||
import { type SearchableBehaviorMetadata } from '../AssetStore/BehaviorStore/BehaviorStoreContext';
|
||||
import { type BehaviorShortHeader } from '../Utils/GDevelopServices/Extension';
|
||||
import EventsFunctionsExtensionsContext from '../EventsFunctionsExtensionsLoader/EventsFunctionsExtensionsContext';
|
||||
import { installExtension } from '../AssetStore/ExtensionStore/InstallExtension';
|
||||
@@ -90,7 +89,7 @@ export default function NewBehaviorDialog({
|
||||
[project]
|
||||
);
|
||||
|
||||
const allInstalledBehaviorMetadataList: Array<SearchableBehaviorMetadata> = React.useMemo(
|
||||
const allInstalledBehaviorMetadataList: Array<BehaviorShortHeader> = React.useMemo(
|
||||
() => {
|
||||
const platform = project.getCurrentPlatform();
|
||||
const behaviorMetadataList =
|
||||
@@ -115,12 +114,29 @@ export default function NewBehaviorDialog({
|
||||
behavior.behaviorMetadata
|
||||
),
|
||||
tags: behavior.tags,
|
||||
name: gd.PlatformExtension.getBehaviorNameFromFullBehaviorType(
|
||||
behavior.type
|
||||
),
|
||||
extensionName: gd.PlatformExtension.getExtensionFromFullBehaviorType(
|
||||
behavior.type
|
||||
),
|
||||
|
||||
isInstalled: true,
|
||||
// The tier will be overridden with repository data.
|
||||
// Only the built-in and user extensions will keep this value.
|
||||
tier: 'installed',
|
||||
// Not relevant for `installed` extensions
|
||||
version: '',
|
||||
url: '',
|
||||
headerUrl: '',
|
||||
extensionNamespace: '',
|
||||
authorIds: [],
|
||||
}));
|
||||
},
|
||||
[project, eventsFunctionsExtension, getAllRequiredBehaviorTypes]
|
||||
);
|
||||
|
||||
const installedBehaviorMetadataList: Array<SearchableBehaviorMetadata> = React.useMemo(
|
||||
const installedBehaviorMetadataList: Array<BehaviorShortHeader> = React.useMemo(
|
||||
() =>
|
||||
allInstalledBehaviorMetadataList.filter(
|
||||
behavior => !deprecatedBehaviorsInformation[behavior.type]
|
||||
@@ -128,7 +144,7 @@ export default function NewBehaviorDialog({
|
||||
[allInstalledBehaviorMetadataList, deprecatedBehaviorsInformation]
|
||||
);
|
||||
|
||||
const deprecatedBehaviorMetadataList: Array<SearchableBehaviorMetadata> = React.useMemo(
|
||||
const deprecatedBehaviorMetadataList: Array<BehaviorShortHeader> = React.useMemo(
|
||||
() => {
|
||||
const deprecatedBehaviors = allInstalledBehaviorMetadataList.filter(
|
||||
behavior => deprecatedBehaviorsInformation[behavior.type]
|
||||
|
@@ -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,
|
||||
|
538
newIDE/app/src/EditorFunctions/ApplyEventsChanges.js
Normal file
538
newIDE/app/src/EditorFunctions/ApplyEventsChanges.js
Normal file
@@ -0,0 +1,538 @@
|
||||
// @flow
|
||||
import { unserializeFromJSObject } from '../Utils/Serializer';
|
||||
import {
|
||||
type AiGeneratedEventChange,
|
||||
type AiGeneratedEventUndeclaredVariable,
|
||||
} from '../Utils/GDevelopServices/Generation';
|
||||
import { mapFor } from '../Utils/MapFor';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
/**
|
||||
* Parses an event path string (e.g., "event-0.1.2") into an array of 0-based indices (e.g., [0, 1, 2]).
|
||||
* Throws an error for invalid formats or non-positive indices.
|
||||
*/
|
||||
const parseEventPath = (pathString: string): Array<number> => {
|
||||
const originalPathString = pathString;
|
||||
if (!pathString.startsWith('event-')) {
|
||||
// Fallback for paths that might not have the "event-" prefix, like "1.2.3"
|
||||
// This is a lenient parsing, primary expectation is "event-" prefix.
|
||||
const partsNoPrefix = pathString.split('.');
|
||||
if (
|
||||
partsNoPrefix.length > 0 &&
|
||||
partsNoPrefix.every(s => s !== '' && !isNaN(parseInt(s, 10)))
|
||||
) {
|
||||
console.warn(
|
||||
`Event path string "${originalPathString}" does not start with "event-". Parsed as direct indices.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid event path string format: "${originalPathString}". Expected "event-X.Y.Z" or "X.Y.Z".`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
pathString = pathString.substring('event-'.length);
|
||||
}
|
||||
|
||||
const parts = pathString.split('.');
|
||||
if (
|
||||
parts.length === 0 ||
|
||||
parts.some(s => s === '' || isNaN(parseInt(s, 10)))
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid event path string content: "${originalPathString}". Ensure numbers are separated by dots.`
|
||||
);
|
||||
}
|
||||
return parts.map(s => {
|
||||
const num = parseInt(s, 10);
|
||||
if (num < 0) {
|
||||
throw new Error(
|
||||
`Event path indices must be positive in string "${originalPathString}", but found ${num}.`
|
||||
);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigates an event tree to find the parent EventsList and the 0-based index
|
||||
* for an event targeted by the given path.
|
||||
*/
|
||||
const getParentListAndIndex = (
|
||||
rootEventsList: gdEventsList,
|
||||
path: Array<number>,
|
||||
operationTypeForErrorMessage: 'access' | 'insertion'
|
||||
): { parentList: gdEventsList, eventIndexInParentList: number } => {
|
||||
if (path.length === 0) {
|
||||
throw new Error('Path cannot be empty for getParentListAndIndex.');
|
||||
}
|
||||
|
||||
let currentList = rootEventsList;
|
||||
const pathForErrorMessage = path.join('.');
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const eventIndex = path[i];
|
||||
if (eventIndex < 0 || eventIndex >= currentList.getEventsCount()) {
|
||||
throw new Error(
|
||||
`Invalid event path: index ${eventIndex} out of bounds at depth ${i +
|
||||
1} (max: ${currentList.getEventsCount() -
|
||||
1}). Path: ${pathForErrorMessage}`
|
||||
);
|
||||
}
|
||||
const event = currentList.getEventAt(eventIndex);
|
||||
if (!event.canHaveSubEvents()) {
|
||||
throw new Error(
|
||||
`Event at path segment ${i +
|
||||
1} (index ${eventIndex}) cannot have sub-events. Path: ${pathForErrorMessage}`
|
||||
);
|
||||
}
|
||||
currentList = event.getSubEvents();
|
||||
}
|
||||
|
||||
const finalIndex = path[path.length - 1];
|
||||
if (finalIndex < 0) {
|
||||
throw new Error(
|
||||
`Invalid event path: final index ${finalIndex} is negative. Path: ${pathForErrorMessage}.`
|
||||
);
|
||||
}
|
||||
|
||||
// For insertion, index can be equal to count (to append). For access, it must be less than count.
|
||||
if (
|
||||
operationTypeForErrorMessage === 'insertion' &&
|
||||
finalIndex > currentList.getEventsCount()
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid event path for insertion: final index ${finalIndex} is out of bounds. Max allowed for insertion: ${currentList.getEventsCount()}. Path: ${pathForErrorMessage}`
|
||||
);
|
||||
} else if (
|
||||
operationTypeForErrorMessage === 'access' &&
|
||||
finalIndex >= currentList.getEventsCount()
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid event path for access: final index ${finalIndex} is out of bounds. Max allowed for access: ${currentList.getEventsCount() -
|
||||
1}. Path: ${pathForErrorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
return { parentList: currentList, eventIndexInParentList: finalIndex };
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves an event at a specific path from a root EventsList.
|
||||
*/
|
||||
const getEventByPath = (
|
||||
rootEventsList: gdEventsList,
|
||||
path: Array<number>
|
||||
): gdBaseEvent => {
|
||||
const { parentList, eventIndexInParentList } = getParentListAndIndex(
|
||||
rootEventsList,
|
||||
path,
|
||||
'access'
|
||||
);
|
||||
// Bounds check already done by getParentListAndIndex for 'access'
|
||||
return parentList.getEventAt(eventIndexInParentList);
|
||||
};
|
||||
|
||||
type EventOperationType = 'delete' | 'insert' | 'insertAsSub';
|
||||
type EventOperation = {|
|
||||
type: EventOperationType,
|
||||
path: Array<number>,
|
||||
eventsToInsert?: gdEventsList,
|
||||
|};
|
||||
|
||||
const comparePathsReverseLexicographically = (
|
||||
p1: Array<number>,
|
||||
p2: Array<number>
|
||||
): number => {
|
||||
const maxLength = Math.max(p1.length, p2.length);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const val1 = i < p1.length ? p1[i] : -1;
|
||||
const val2 = i < p2.length ? p2[i] : -1;
|
||||
if (val1 > val2) return -1;
|
||||
if (val1 < val2) return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const applyEventsChanges = (
|
||||
project: gdProject,
|
||||
sceneEvents: gdEventsList,
|
||||
eventOperationsInput: Array<AiGeneratedEventChange>,
|
||||
aiGeneratedEventId: string
|
||||
): void => {
|
||||
const operations: Array<EventOperation> = [];
|
||||
|
||||
eventOperationsInput.forEach(change => {
|
||||
const { operationName, operationTargetEvent, generatedEvents } = change;
|
||||
let parsedPath: Array<number> | null = null;
|
||||
let localEventsToInsert: gdEventsList | null = null;
|
||||
|
||||
try {
|
||||
if (operationTargetEvent) {
|
||||
parsedPath = parseEventPath(operationTargetEvent);
|
||||
} else if (operationName !== 'insert_at_end') {
|
||||
// Path is generally required, except for 'insert_at_end'.
|
||||
console.warn(
|
||||
`Skipping operation "${operationName}" due to missing operationTargetEvent path.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (generatedEvents && operationName !== 'delete_event') {
|
||||
const eventsListContent = JSON.parse(generatedEvents);
|
||||
localEventsToInsert = new gd.EventsList();
|
||||
unserializeFromJSObject(
|
||||
localEventsToInsert,
|
||||
eventsListContent,
|
||||
'unserializeFrom',
|
||||
project
|
||||
);
|
||||
if (localEventsToInsert.isEmpty()) {
|
||||
console.warn(
|
||||
`Generated events for operation "${operationName}" (path: ${operationTargetEvent ||
|
||||
'N/A'}) are empty. Insertion might not add any events.`
|
||||
);
|
||||
}
|
||||
mapFor(0, localEventsToInsert.getEventsCount(), i => {
|
||||
if (!localEventsToInsert) return;
|
||||
|
||||
const event = localEventsToInsert.getEventAt(i);
|
||||
event.setAiGeneratedEventId(aiGeneratedEventId);
|
||||
});
|
||||
}
|
||||
|
||||
switch (operationName) {
|
||||
case 'insert_and_replace_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "insert_and_replace_event" due to missing or invalid path.`
|
||||
);
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
return;
|
||||
}
|
||||
operations.push({ type: 'delete', path: parsedPath });
|
||||
operations.push({
|
||||
type: 'insert',
|
||||
path: parsedPath,
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
// localEventsToInsert is now "owned" by the 'insert' operation,
|
||||
// it should not be deleted here in the switch case.
|
||||
break;
|
||||
case 'insert_before_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "insert_before_event" due to missing or invalid path.`
|
||||
);
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
return;
|
||||
}
|
||||
operations.push({
|
||||
type: 'insert',
|
||||
path: parsedPath,
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
break;
|
||||
case 'insert_as_sub_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "insert_as_sub_event" due to missing or invalid path.`
|
||||
);
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
return;
|
||||
}
|
||||
operations.push({
|
||||
type: 'insertAsSub',
|
||||
path: parsedPath, // This path is to the parent event
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
break;
|
||||
case 'delete_event':
|
||||
if (!parsedPath) {
|
||||
console.warn(
|
||||
`Skipping "delete_event" due to missing or invalid path.`
|
||||
);
|
||||
// No localEventsToInsert expected or created for delete_event.
|
||||
return;
|
||||
}
|
||||
// Ensure no events were accidentally parsed for delete.
|
||||
if (localEventsToInsert) {
|
||||
console.warn(
|
||||
'Internal warning: localEventsToInsert was populated for a "delete_event". Cleaning up.'
|
||||
);
|
||||
localEventsToInsert.delete();
|
||||
}
|
||||
operations.push({ type: 'delete', path: parsedPath });
|
||||
break;
|
||||
case 'insert_at_end':
|
||||
// Path for insert_at_end is synthetic, representing the end of the root list.
|
||||
operations.push({
|
||||
type: 'insert',
|
||||
path: [sceneEvents.getEventsCount()],
|
||||
eventsToInsert: localEventsToInsert || undefined,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`Unknown operationName: "${operationName}". Skipping operation.`
|
||||
);
|
||||
// Clean up localEventsToInsert if it was created for an unknown operation
|
||||
if (localEventsToInsert) localEventsToInsert.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Error processing event change (operation: "${operationName}", path: "${operationTargetEvent ||
|
||||
'N/A'}"): ${e.message}. Skipping this change.`
|
||||
);
|
||||
// Ensure cleanup if parsing/unserialization failed mid-way
|
||||
if (localEventsToInsert) {
|
||||
localEventsToInsert.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
operations.sort((opA, opB) => {
|
||||
const pathComparison = comparePathsReverseLexicographically(
|
||||
opA.path,
|
||||
opB.path
|
||||
);
|
||||
if (pathComparison !== 0) return pathComparison;
|
||||
if (opA.type === 'delete' && opB.type !== 'delete') return -1;
|
||||
if (opA.type !== 'delete' && opB.type === 'delete') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
operations.forEach(op => {
|
||||
const pathForLog = op.path.join('.');
|
||||
try {
|
||||
if (op.type === 'delete') {
|
||||
const { parentList, eventIndexInParentList } = getParentListAndIndex(
|
||||
sceneEvents,
|
||||
op.path,
|
||||
'access' // Deleting an existing event, so 'access'
|
||||
);
|
||||
// Check already done by getParentListAndIndex for 'access'
|
||||
parentList.removeEventAt(eventIndexInParentList);
|
||||
} else if (op.type === 'insert') {
|
||||
const {
|
||||
parentList,
|
||||
eventIndexInParentList: insertionIndex,
|
||||
} = getParentListAndIndex(
|
||||
sceneEvents,
|
||||
op.path,
|
||||
'insertion' // Path is for insertion point
|
||||
);
|
||||
// Check already done by getParentListAndIndex for 'insertion'
|
||||
if (op.eventsToInsert && !op.eventsToInsert.isEmpty()) {
|
||||
parentList.insertEvents(
|
||||
op.eventsToInsert,
|
||||
0,
|
||||
op.eventsToInsert.getEventsCount(),
|
||||
insertionIndex
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`Insert operation for path [${pathForLog}] skipped: no events to insert or events list is empty.`
|
||||
);
|
||||
}
|
||||
} else if (op.type === 'insertAsSub') {
|
||||
// op.path is the path to the PARENT event
|
||||
const parentEvent = getEventByPath(sceneEvents, op.path);
|
||||
if (!parentEvent.canHaveSubEvents()) {
|
||||
console.warn(
|
||||
`Cannot insert sub-events: Event at path [${pathForLog}] does not support sub-events. Skipping.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const subEventsList = parentEvent.getSubEvents();
|
||||
if (op.eventsToInsert && !op.eventsToInsert.isEmpty()) {
|
||||
subEventsList.insertEvents(
|
||||
op.eventsToInsert,
|
||||
0,
|
||||
op.eventsToInsert.getEventsCount(),
|
||||
subEventsList.getEventsCount() // Insert at the end of sub-events
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`InsertAsSub operation for parent path [${pathForLog}] skipped: no events to insert or events list is empty.`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error applying event operation type ${
|
||||
op.type
|
||||
} for path [${pathForLog}]:`,
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
// Clean up the gd.EventsList associated with this operation, if any.
|
||||
if (op.eventsToInsert) {
|
||||
op.eventsToInsert.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const addUndeclaredVariables = ({
|
||||
project,
|
||||
scene,
|
||||
undeclaredVariables,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
scene: gdLayout,
|
||||
undeclaredVariables: Array<AiGeneratedEventUndeclaredVariable>,
|
||||
|}) => {
|
||||
undeclaredVariables.forEach(variable => {
|
||||
const { name, type, requiredScope } = variable;
|
||||
let newVariable = null;
|
||||
if (requiredScope === 'global') {
|
||||
if (!project.getVariables().has(name)) {
|
||||
newVariable = project.getVariables().insertNew(name, 0);
|
||||
}
|
||||
} else if (requiredScope === 'scene' || requiredScope === 'none') {
|
||||
if (!scene.getVariables().has(name)) {
|
||||
newVariable = scene.getVariables().insertNew(name, 0);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`Unknown requiredScope for undeclared variable: ${name}. Skipping.`
|
||||
);
|
||||
}
|
||||
|
||||
if (newVariable && type) {
|
||||
const lowerCaseType = type.toLowerCase();
|
||||
newVariable.castTo(
|
||||
lowerCaseType === 'string'
|
||||
? 'String'
|
||||
: lowerCaseType === 'boolean'
|
||||
? 'Boolean'
|
||||
: lowerCaseType === 'array'
|
||||
? 'Array'
|
||||
: lowerCaseType === 'structure'
|
||||
? 'Structure'
|
||||
: 'Number'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const addObjectUndeclaredVariables = ({
|
||||
project,
|
||||
scene,
|
||||
objectName,
|
||||
undeclaredVariables,
|
||||
}: {|
|
||||
project: gdProject,
|
||||
scene: gdLayout,
|
||||
objectName: string,
|
||||
undeclaredVariables: Array<AiGeneratedEventUndeclaredVariable>,
|
||||
|}) => {
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
|
||||
project,
|
||||
scene
|
||||
);
|
||||
|
||||
const setupVariable = (variable: gdVariable, type: string | null) => {
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const lowerCaseType = type.toLowerCase();
|
||||
variable.castTo(
|
||||
lowerCaseType === 'string'
|
||||
? 'String'
|
||||
: lowerCaseType === 'boolean'
|
||||
? 'Boolean'
|
||||
: lowerCaseType === 'array'
|
||||
? 'Array'
|
||||
: lowerCaseType === 'structure'
|
||||
? 'Structure'
|
||||
: 'Number'
|
||||
);
|
||||
};
|
||||
|
||||
const addVariableForObjectsOfGroup = (
|
||||
group: gdObjectGroup,
|
||||
undeclaredVariable: AiGeneratedEventUndeclaredVariable
|
||||
) => {
|
||||
const groupVariablesContainer = gd.ObjectVariableHelper.mergeVariableContainers(
|
||||
projectScopedContainers.getObjectsContainersList(),
|
||||
group
|
||||
);
|
||||
const originalSerializedVariables = new gd.SerializerElement();
|
||||
groupVariablesContainer.serializeTo(originalSerializedVariables);
|
||||
|
||||
const variable = groupVariablesContainer.insertNew(
|
||||
undeclaredVariable.name,
|
||||
0
|
||||
);
|
||||
setupVariable(variable, undeclaredVariable.type);
|
||||
|
||||
const changeset = gd.WholeProjectRefactorer.computeChangesetForVariablesContainer(
|
||||
originalSerializedVariables,
|
||||
groupVariablesContainer
|
||||
);
|
||||
originalSerializedVariables.delete();
|
||||
|
||||
gd.WholeProjectRefactorer.applyRefactoringForGroupVariablesContainer(
|
||||
project,
|
||||
project.getObjects(),
|
||||
scene.getObjects(),
|
||||
scene.getInitialInstances(),
|
||||
groupVariablesContainer,
|
||||
group,
|
||||
changeset,
|
||||
originalSerializedVariables
|
||||
);
|
||||
};
|
||||
|
||||
undeclaredVariables.forEach(undeclaredVariable => {
|
||||
if (
|
||||
projectScopedContainers
|
||||
.getObjectsContainersList()
|
||||
.hasObjectOrGroupWithVariableNamed(objectName, undeclaredVariable.name)
|
||||
) {
|
||||
// Variable already exists, no need to add it.
|
||||
return;
|
||||
}
|
||||
|
||||
if (scene.getObjects().hasObjectNamed(objectName)) {
|
||||
const object = scene.getObjects().getObject(objectName);
|
||||
const variable = object
|
||||
.getVariables()
|
||||
.insertNew(undeclaredVariable.name, 0);
|
||||
setupVariable(variable, undeclaredVariable.type);
|
||||
} else if (
|
||||
scene
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.has(objectName)
|
||||
) {
|
||||
const group = scene
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.get(objectName);
|
||||
|
||||
addVariableForObjectsOfGroup(group, undeclaredVariable);
|
||||
} else if (project.getObjects().hasObjectNamed(objectName)) {
|
||||
const object = project.getObjects().getObject(objectName);
|
||||
const variable = object
|
||||
.getVariables()
|
||||
.insertNew(undeclaredVariable.name, 0);
|
||||
setupVariable(variable, undeclaredVariable.type);
|
||||
}
|
||||
if (
|
||||
project
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.has(objectName)
|
||||
) {
|
||||
const group = project
|
||||
.getObjects()
|
||||
.getObjectGroups()
|
||||
.get(objectName);
|
||||
|
||||
addVariableForObjectsOfGroup(group, undeclaredVariable);
|
||||
}
|
||||
});
|
||||
};
|
376
newIDE/app/src/EditorFunctions/ApplyEventsChanges.spec.js
Normal file
376
newIDE/app/src/EditorFunctions/ApplyEventsChanges.spec.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// @flow
|
||||
import { applyEventsChanges } from './ApplyEventsChanges';
|
||||
import { type AiGeneratedEventChange } from '../Utils/GDevelopServices/Generation';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('applyEventsChanges', () => {
|
||||
let project: gdProject;
|
||||
let sceneEventsList: gdEventsList;
|
||||
let newEventsForInsertionJson = ` [{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]},{"type":"BuiltinCommonInstructions::Standard","conditions":[],"actions":[]}]`;
|
||||
const fakeGeneratedEventId = 'fakeGeneratedEventId';
|
||||
|
||||
beforeEach(() => {
|
||||
project = new gd.ProjectHelper.createNewGDJSProject();
|
||||
sceneEventsList = new gd.EventsList();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sceneEventsList.delete();
|
||||
project.delete();
|
||||
});
|
||||
|
||||
const setupInitialSceneEvents = (eventTypes: Array<string>) => {
|
||||
sceneEventsList.clear();
|
||||
eventTypes.forEach((type, index) => {
|
||||
sceneEventsList.insertNewEvent(project, type, index);
|
||||
});
|
||||
};
|
||||
|
||||
const getEventTypes = (list: gdEventsList): Array<string> => {
|
||||
const types = [];
|
||||
for (let i = 0; i < list.getEventsCount(); i++) {
|
||||
types.push(list.getEventAt(i).getType());
|
||||
}
|
||||
return types;
|
||||
};
|
||||
|
||||
it('should delete an event at the specified path', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Comment',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
]);
|
||||
// Path "event-1" targets index 1 (the second event)
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
expect(sceneEventsList.getEventsCount()).toBe(2);
|
||||
expect(sceneEventsList.getEventAt(0).getType()).toBe(
|
||||
'BuiltinCommonInstructions::Standard'
|
||||
);
|
||||
expect(sceneEventsList.getEventAt(1).getType()).toBe(
|
||||
'BuiltinCommonInstructions::Standard'
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete a sub-event', () => {
|
||||
sceneEventsList.clear();
|
||||
const parentEvent = gd.asGroupEvent(
|
||||
sceneEventsList.insertNewEvent(
|
||||
project,
|
||||
'BuiltinCommonInstructions::Group',
|
||||
0
|
||||
)
|
||||
);
|
||||
const subEvents = parentEvent.getSubEvents();
|
||||
subEvents.insertNewEvent(project, 'BuiltinCommonInstructions::Comment', 0); // sub-event 0
|
||||
subEvents.insertNewEvent(project, 'BuiltinCommonInstructions::Standard', 1); // sub-event 1 (to delete)
|
||||
|
||||
// Path "event-0.1" targets parent at index 0, sub-event at its index 1
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-0.1',
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
|
||||
const finalParentEvent = sceneEventsList.getEventAt(0);
|
||||
expect(finalParentEvent.getSubEvents().getEventsCount()).toBe(1);
|
||||
expect(
|
||||
finalParentEvent
|
||||
.getSubEvents()
|
||||
.getEventAt(0)
|
||||
.getType()
|
||||
).toBe('BuiltinCommonInstructions::Comment');
|
||||
});
|
||||
|
||||
it('should insert events before a specified path', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
]);
|
||||
// Path "event-1" means insert at index 1 (before Repeat)
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_before_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Expected: S0, NewS, NewS, R1
|
||||
expect(sceneEventsList.getEventsCount()).toBe(4);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should append events if path targets end of list', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
]); // S0, R1. Count = 2
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_at_end',
|
||||
operationTargetEvent: null,
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Expected: S0, R1, NewS, NewS
|
||||
expect(sceneEventsList.getEventsCount()).toBe(4);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should insert events as sub-events', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Group',
|
||||
]); // S0, G1
|
||||
// Path "event-1" targets parent at index 1 ('Group')
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_as_sub_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
const parentEvent = sceneEventsList.getEventAt(1);
|
||||
expect(parentEvent.getSubEvents().getEventsCount()).toBe(2);
|
||||
expect(getEventTypes(parentEvent.getSubEvents())).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should replace an event', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
'BuiltinCommonInstructions::While',
|
||||
]);
|
||||
// Path "event-1" targets index 1 ('Repeat') for replacement
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_and_replace_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Expected: S0, NewS, NewS, W2 (original R1 is gone, 2 new events inserted)
|
||||
expect(sceneEventsList.getEventsCount()).toBe(4);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::While',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process deletions before insertions when paths are sorted', () => {
|
||||
setupInitialSceneEvents([
|
||||
'BuiltinCommonInstructions::Standard',
|
||||
'BuiltinCommonInstructions::Repeat',
|
||||
'BuiltinCommonInstructions::While',
|
||||
'BuiltinCommonInstructions::ForEach',
|
||||
]);
|
||||
// Delete W2 (idx 2, path "event-2")
|
||||
// Insert new before R1 (idx 1, path "event-1")
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-2',
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
{
|
||||
operationName: 'insert_before_event',
|
||||
operationTargetEvent: 'event-1',
|
||||
generatedEvents: newEventsForInsertionJson,
|
||||
isEventsJsonValid: true,
|
||||
areEventsValid: true,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
// Initial: S0, R1, W2, F3
|
||||
// After del W2: S0, R1, F3
|
||||
// After insert new before R1: S0, NewS, NewS, R1, F3
|
||||
expect(sceneEventsList.getEventsCount()).toBe(5);
|
||||
expect(getEventTypes(sceneEventsList)).toEqual([
|
||||
'BuiltinCommonInstructions::Standard', // S0
|
||||
'BuiltinCommonInstructions::Standard', // NewS
|
||||
'BuiltinCommonInstructions::Standard', // NewS
|
||||
'BuiltinCommonInstructions::Repeat', // R1
|
||||
'BuiltinCommonInstructions::ForEach', // F3
|
||||
]);
|
||||
});
|
||||
|
||||
it('should warn and skip insert if no generatedEvents provided for an insert operation', () => {
|
||||
setupInitialSceneEvents(['BuiltinCommonInstructions::Standard']);
|
||||
const consoleWarnSpy = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'insert_before_event',
|
||||
operationTargetEvent: 'event-1', // Insert at index 1 (append)
|
||||
generatedEvents: null, // No events provided
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
|
||||
expect(sceneEventsList.getEventsCount()).toBe(1); // No events added
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Insert operation for path [1] skipped: no events to insert or events list is empty'
|
||||
)
|
||||
);
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip invalid delete path (out of bounds) gracefully and log error', () => {
|
||||
setupInitialSceneEvents(['BuiltinCommonInstructions::Standard']);
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const eventOperations: Array<AiGeneratedEventChange> = [
|
||||
{
|
||||
operationName: 'delete_event',
|
||||
operationTargetEvent: 'event-5', // Path "event-5" targets index 5, out of bounds
|
||||
generatedEvents: null,
|
||||
isEventsJsonValid: null,
|
||||
areEventsValid: null,
|
||||
diagnosticLines: [],
|
||||
extensionNames: [],
|
||||
undeclaredVariables: [],
|
||||
undeclaredObjectVariables: {},
|
||||
},
|
||||
];
|
||||
applyEventsChanges(
|
||||
project,
|
||||
sceneEventsList,
|
||||
eventOperations,
|
||||
fakeGeneratedEventId
|
||||
);
|
||||
expect(sceneEventsList.getEventsCount()).toBe(1); // No change
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Error applying event operation type delete for path [5]'
|
||||
),
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
174
newIDE/app/src/EditorFunctions/ApplyVariableChange.js
Normal file
174
newIDE/app/src/EditorFunctions/ApplyVariableChange.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// @flow
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const readOrInferVariableType = (
|
||||
specifiedType: string | null,
|
||||
value: string
|
||||
): string => {
|
||||
if (specifiedType) {
|
||||
const lowercaseSpecifiedType = specifiedType.toLowerCase();
|
||||
if (lowercaseSpecifiedType === 'string') {
|
||||
return 'String';
|
||||
} else if (lowercaseSpecifiedType === 'number') {
|
||||
return 'Number';
|
||||
} else if (lowercaseSpecifiedType === 'boolean') {
|
||||
return 'Boolean';
|
||||
}
|
||||
}
|
||||
|
||||
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
|
||||
return 'Boolean';
|
||||
}
|
||||
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isNaN(numberValue)) {
|
||||
return 'Number';
|
||||
}
|
||||
|
||||
return 'String';
|
||||
};
|
||||
|
||||
// Parse variable path to handle both dot notation and array indexing
|
||||
const parseVariablePath = (
|
||||
variablePath: string
|
||||
): Array<{| type: 'property' | 'index', value: string |}> => {
|
||||
const segments = [];
|
||||
let currentSegment = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < variablePath.length) {
|
||||
const char = variablePath[i];
|
||||
|
||||
if (char === '.') {
|
||||
// End of property segment
|
||||
if (currentSegment.trim()) {
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
currentSegment = '';
|
||||
}
|
||||
i++;
|
||||
} else if (char === '[') {
|
||||
// Start of array index
|
||||
if (currentSegment.trim()) {
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
currentSegment = '';
|
||||
}
|
||||
|
||||
// Find the closing bracket
|
||||
i++; // Skip opening bracket
|
||||
let indexContent = '';
|
||||
let foundClosingBracketWithProperIndex = false;
|
||||
while (i < variablePath.length && variablePath[i] !== ']') {
|
||||
indexContent += variablePath[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i < variablePath.length && variablePath[i] === ']') {
|
||||
// Valid array index
|
||||
const indexValue = indexContent.trim();
|
||||
if (indexValue && !isNaN(parseInt(indexValue, 10))) {
|
||||
segments.push({ type: 'index', value: indexValue });
|
||||
foundClosingBracketWithProperIndex = true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Content of the index is invalid ("${indexValue}") - it should be a number.`
|
||||
);
|
||||
}
|
||||
i++; // Skip closing bracket
|
||||
}
|
||||
|
||||
if (!foundClosingBracketWithProperIndex) {
|
||||
throw new Error(
|
||||
'Improperly formatted array index. Please check the variable path - it should be formatted like this: `myVar[1].prop`, `myVar`, `myVar.prop`, etc...'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
currentSegment += char;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining segment
|
||||
if (currentSegment.trim()) {
|
||||
segments.push({ type: 'property', value: currentSegment.trim() });
|
||||
}
|
||||
|
||||
console.log('segments', segments);
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
export const applyVariableChange = ({
|
||||
variablePath,
|
||||
forcedVariableType,
|
||||
variablesContainer,
|
||||
value,
|
||||
}: {|
|
||||
variablePath: string,
|
||||
forcedVariableType: string | null,
|
||||
variablesContainer: gd.VariablesContainer,
|
||||
value: string,
|
||||
|}) => {
|
||||
const pathSegments = parseVariablePath(variablePath);
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
throw new Error('Invalid variable path');
|
||||
}
|
||||
|
||||
let addedNewVariable = false;
|
||||
const firstSegment = pathSegments[0];
|
||||
|
||||
if (firstSegment.type !== 'property') {
|
||||
throw new Error('Variable path must start with a property name');
|
||||
}
|
||||
|
||||
const firstVariableName = firstSegment.value;
|
||||
let variable = null;
|
||||
if (!variablesContainer.has(firstVariableName)) {
|
||||
variable = variablesContainer.insertNew(firstVariableName, 0);
|
||||
addedNewVariable = true;
|
||||
} else {
|
||||
variable = variablesContainer.get(firstVariableName);
|
||||
}
|
||||
|
||||
for (let i = 1; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i];
|
||||
|
||||
if (segment.type === 'property') {
|
||||
// Navigate to structure property
|
||||
variable.castTo('Structure');
|
||||
if (!variable.hasChild(segment.value)) {
|
||||
addedNewVariable = true;
|
||||
}
|
||||
variable = variable.getChild(segment.value);
|
||||
} else if (segment.type === 'index') {
|
||||
// Navigate to array element
|
||||
const index = parseInt(segment.value, 10);
|
||||
variable.castTo('Array');
|
||||
|
||||
// Ensure array has enough elements
|
||||
while (variable.getChildrenCount() <= index) {
|
||||
variable.pushNew();
|
||||
addedNewVariable = true;
|
||||
}
|
||||
|
||||
variable = variable.getAtIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
const variableType = readOrInferVariableType(forcedVariableType, value);
|
||||
|
||||
if (variableType === 'String') {
|
||||
variable.setString(value);
|
||||
} else if (variableType === 'Number') {
|
||||
variable.setValue(parseFloat(value));
|
||||
} else if (variableType === 'Boolean') {
|
||||
variable.setBool(value.toLowerCase() === 'true');
|
||||
}
|
||||
|
||||
return {
|
||||
variable,
|
||||
variableType,
|
||||
addedNewVariable,
|
||||
};
|
||||
};
|
399
newIDE/app/src/EditorFunctions/ApplyVariableChange.spec.js
Normal file
399
newIDE/app/src/EditorFunctions/ApplyVariableChange.spec.js
Normal file
@@ -0,0 +1,399 @@
|
||||
// @flow
|
||||
import { applyVariableChange } from './ApplyVariableChange';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('applyVariableChange', () => {
|
||||
let variablesContainer: gdVariablesContainer;
|
||||
|
||||
beforeEach(() => {
|
||||
variablesContainer = new gd.VariablesContainer(
|
||||
gd.VariablesContainer.Unknown
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
variablesContainer.delete();
|
||||
});
|
||||
|
||||
describe('Root variable modification', () => {
|
||||
it('should create and set a new root string variable', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myVariable',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'hello world',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myVariable')).toBe(true);
|
||||
|
||||
const variable = variablesContainer.get('myVariable');
|
||||
expect(variable.getType()).toBe(gd.Variable.String);
|
||||
expect(variable.getString()).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should create and set a new root number variable', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myNum',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: '42.5',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myNum')).toBe(true);
|
||||
|
||||
const variable = variablesContainer.get('myNum');
|
||||
expect(variable.getType()).toBe(gd.Variable.Number);
|
||||
expect(variable.getValue()).toBe(42.5);
|
||||
});
|
||||
|
||||
it('should create and set a new root boolean variable', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myBool',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'true',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myBool')).toBe(true);
|
||||
|
||||
const variable = variablesContainer.get('myBool');
|
||||
expect(variable.getType()).toBe(gd.Variable.Boolean);
|
||||
expect(variable.getBool()).toBe(true);
|
||||
});
|
||||
|
||||
it('should modify an existing root variable', () => {
|
||||
// Setup existing variable
|
||||
const existingVar = variablesContainer.insertNew('existing', 0);
|
||||
existingVar.setString('old value');
|
||||
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'existing',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'new value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(false);
|
||||
expect(variablesContainer.get('existing').getString()).toBe('new value');
|
||||
});
|
||||
|
||||
it('should respect forced variable type', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'forcedString',
|
||||
forcedVariableType: 'String',
|
||||
variablesContainer,
|
||||
value: '123',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
const variable = variablesContainer.get('forcedString');
|
||||
expect(variable.getType()).toBe(gd.Variable.String);
|
||||
expect(variable.getString()).toBe('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structure child modification', () => {
|
||||
it('should create a new structure with child property', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myStruct.childProperty',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'child value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myStruct')).toBe(true);
|
||||
|
||||
const structVar = variablesContainer.get('myStruct');
|
||||
expect(structVar.getType()).toBe(gd.Variable.Structure);
|
||||
expect(structVar.hasChild('childProperty')).toBe(true);
|
||||
|
||||
const childVar = structVar.getChild('childProperty');
|
||||
expect(childVar.getType()).toBe(gd.Variable.String);
|
||||
expect(childVar.getString()).toBe('child value');
|
||||
});
|
||||
|
||||
it('should add property to existing structure', () => {
|
||||
// Setup existing structure
|
||||
const existingStruct = variablesContainer.insertNew('existingStruct', 0);
|
||||
existingStruct.castTo('Structure');
|
||||
existingStruct.getChild('existingProp').setString('existing');
|
||||
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'existingStruct.newProperty',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'new prop value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(existingStruct.hasChild('existingProp')).toBe(true);
|
||||
expect(existingStruct.hasChild('newProperty')).toBe(true);
|
||||
expect(existingStruct.getChild('newProperty').getString()).toBe(
|
||||
'new prop value'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nested structure properties', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'level1.level2.level3',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'deep value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const level1 = variablesContainer.get('level1');
|
||||
expect(level1.getType()).toBe(gd.Variable.Structure);
|
||||
|
||||
const level2 = level1.getChild('level2');
|
||||
expect(level2.getType()).toBe(gd.Variable.Structure);
|
||||
|
||||
const level3 = level2.getChild('level3');
|
||||
expect(level3.getString()).toBe('deep value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array item modification', () => {
|
||||
it('should create a new array and set item at index', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myArray[2]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'third item',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(variablesContainer.has('myArray')).toBe(true);
|
||||
|
||||
const arrayVar = variablesContainer.get('myArray');
|
||||
expect(arrayVar.getType()).toBe(gd.Variable.Array);
|
||||
expect(arrayVar.getChildrenCount()).toBe(3); // indices 0, 1, 2
|
||||
|
||||
const itemVar = arrayVar.getAtIndex(2);
|
||||
expect(itemVar.getString()).toBe('third item');
|
||||
});
|
||||
|
||||
it('should expand array when accessing higher index', () => {
|
||||
// Setup existing array with 2 items
|
||||
const existingArray = variablesContainer.insertNew('existingArray', 0);
|
||||
existingArray.castTo('Array');
|
||||
existingArray.pushNew().setString('item0');
|
||||
existingArray.pushNew().setString('item1');
|
||||
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'existingArray[4]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'item4',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
expect(existingArray.getChildrenCount()).toBe(5); // indices 0-4
|
||||
expect(existingArray.getAtIndex(0).getString()).toBe('item0');
|
||||
expect(existingArray.getAtIndex(1).getString()).toBe('item1');
|
||||
expect(existingArray.getAtIndex(4).getString()).toBe('item4');
|
||||
});
|
||||
|
||||
it('should handle array index with whitespace', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myArray[ 1 ]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'spaced index',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
const arrayVar = variablesContainer.get('myArray');
|
||||
expect(arrayVar.getAtIndex(1).getString()).toBe('spaced index');
|
||||
});
|
||||
|
||||
it('should modify existing array item', () => {
|
||||
// Setup existing array
|
||||
const existingArray = variablesContainer.insertNew('existingArray', 0);
|
||||
existingArray.castTo('Array');
|
||||
existingArray.pushNew().setString('original');
|
||||
|
||||
applyVariableChange({
|
||||
variablePath: 'existingArray[0]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'modified',
|
||||
});
|
||||
|
||||
expect(existingArray.getAtIndex(0).getString()).toBe('modified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed structure and array access', () => {
|
||||
it('should handle array item with structure property', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myArray[1].itemProperty',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'nested value',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const arrayVar = variablesContainer.get('myArray');
|
||||
expect(arrayVar.getType()).toBe(gd.Variable.Array);
|
||||
expect(arrayVar.getChildrenCount()).toBe(2); // indices 0, 1
|
||||
|
||||
const itemVar = arrayVar.getAtIndex(1);
|
||||
expect(itemVar.getType()).toBe(gd.Variable.Structure);
|
||||
expect(itemVar.hasChild('itemProperty')).toBe(true);
|
||||
expect(itemVar.getChild('itemProperty').getString()).toBe('nested value');
|
||||
});
|
||||
|
||||
it('should handle structure with array property', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'myStruct.arrayProp[2]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'array in struct',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const structVar = variablesContainer.get('myStruct');
|
||||
expect(structVar.getType()).toBe(gd.Variable.Structure);
|
||||
|
||||
const arrayProp = structVar.getChild('arrayProp');
|
||||
expect(arrayProp.getType()).toBe(gd.Variable.Array);
|
||||
expect(arrayProp.getChildrenCount()).toBe(3);
|
||||
expect(arrayProp.getAtIndex(2).getString()).toBe('array in struct');
|
||||
});
|
||||
|
||||
it('should handle complex nested paths', () => {
|
||||
const result = applyVariableChange({
|
||||
variablePath: 'players[0].inventory.items[2].name',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'Magic Sword',
|
||||
});
|
||||
|
||||
expect(result.addedNewVariable).toBe(true);
|
||||
|
||||
const players = variablesContainer.get('players');
|
||||
const player0 = players.getAtIndex(0);
|
||||
const inventory = player0.getChild('inventory');
|
||||
const items = inventory.getChild('items');
|
||||
const item2 = items.getAtIndex(2);
|
||||
const name = item2.getChild('name');
|
||||
|
||||
expect(name.getString()).toBe('Magic Sword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error cases', () => {
|
||||
it('should throw error for empty path', () => {
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: '',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow('Invalid variable path');
|
||||
});
|
||||
|
||||
it('should throw error for path starting with array index', () => {
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: '[0]',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow('Variable path must start with a property name');
|
||||
});
|
||||
|
||||
it('should handle invalid array index gracefully', () => {
|
||||
// Note: The current implementation filters out invalid indexes,
|
||||
// so this test verifies that behavior
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: 'myVar[wrong wrong].prop',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow(
|
||||
'Content of the index is invalid ("wrong wrong") - it should be a number.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed brackets', () => {
|
||||
expect(() => {
|
||||
applyVariableChange({
|
||||
variablePath: 'malformedVar[1.prop',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'test',
|
||||
});
|
||||
}).toThrow(
|
||||
'Improperly formatted array index. Please check the variable path - it should be formatted like this: `myVar[1].prop`, `myVar`, `myVar.prop`, etc...'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type inference', () => {
|
||||
it('should infer boolean type from "true"', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testBool',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'true',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testBool');
|
||||
expect(variable.getType()).toBe(gd.Variable.Boolean);
|
||||
expect(variable.getBool()).toBe(true);
|
||||
});
|
||||
|
||||
it('should infer boolean type from "false"', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testBool',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: 'FALSE',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testBool');
|
||||
expect(variable.getType()).toBe(gd.Variable.Boolean);
|
||||
expect(variable.getBool()).toBe(false);
|
||||
});
|
||||
|
||||
it('should infer number type from numeric strings', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testNum',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: '3.14159',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testNum');
|
||||
expect(variable.getType()).toBe(gd.Variable.Number);
|
||||
expect(variable.getValue()).toBe(3.14159);
|
||||
});
|
||||
|
||||
it('should default to string type for mixed content', () => {
|
||||
applyVariableChange({
|
||||
variablePath: 'testString',
|
||||
forcedVariableType: null,
|
||||
variablesContainer,
|
||||
value: '123abc',
|
||||
});
|
||||
|
||||
const variable = variablesContainer.get('testString');
|
||||
expect(variable.getType()).toBe(gd.Variable.String);
|
||||
expect(variable.getString()).toBe('123abc');
|
||||
});
|
||||
});
|
||||
});
|
153
newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js
Normal file
153
newIDE/app/src/EditorFunctions/EditorFunctionCallRunner.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// @flow
|
||||
import { type EventsGenerationResult } from '.';
|
||||
import {
|
||||
editorFunctions,
|
||||
type EditorFunction,
|
||||
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>,
|
||||
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,
|
||||
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,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
status: 'finished',
|
||||
call_id,
|
||||
success: false,
|
||||
output: { message: error.message || 'Unknown error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
@@ -0,0 +1,259 @@
|
||||
// @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>,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A simplified summary of an expression.
|
||||
*/
|
||||
export type ExpressionSummary = {|
|
||||
type: string,
|
||||
description: string,
|
||||
parameters: Array<ParameterSummary>,
|
||||
|};
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
type: instructionType,
|
||||
description: instructionMetadata.getDescription(),
|
||||
parameters: mapFor(
|
||||
0,
|
||||
instructionMetadata.getParameters().getParametersCount(),
|
||||
index => {
|
||||
const parameterMetadata = instructionMetadata.getParameter(index);
|
||||
return getParameterSummary(parameterMetadata);
|
||||
}
|
||||
),
|
||||
};
|
||||
})
|
||||
.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;
|
||||
|
||||
return {
|
||||
type: expressionType,
|
||||
description: expressionMetadata.getDescription(),
|
||||
parameters: mapFor(
|
||||
0,
|
||||
expressionMetadata.getParameters().getParametersCount(),
|
||||
index => {
|
||||
const parameterMetadata = expressionMetadata.getParameter(index);
|
||||
return getParameterSummary(parameterMetadata);
|
||||
}
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
extension
|
||||
.getExtensionObjectsTypes()
|
||||
.toJSArray()
|
||||
.forEach(objectType => {
|
||||
const objectMetadata = extension.getObjectMetadata(objectType);
|
||||
if (
|
||||
gd.MetadataProvider.isBadObjectMetadata(objectMetadata) ||
|
||||
objectMetadata.isPrivate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
objects[objectType] = {
|
||||
name: objectMetadata.getName(),
|
||||
fullName: objectMetadata.getFullName(),
|
||||
description: objectMetadata.getDescription(),
|
||||
actions: generateInstructionsSummaries({
|
||||
instructionsMetadata: objectMetadata.getAllActions(),
|
||||
}),
|
||||
conditions: generateInstructionsSummaries({
|
||||
instructionsMetadata: objectMetadata.getAllConditions(),
|
||||
}),
|
||||
expressions: [
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: objectMetadata.getAllExpressions(),
|
||||
}),
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: objectMetadata.getAllStrExpressions(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
extension
|
||||
.getBehaviorsTypes()
|
||||
.toJSArray()
|
||||
.forEach(behaviorType => {
|
||||
const behaviorMetadata = extension.getBehaviorMetadata(behaviorType);
|
||||
if (
|
||||
gd.MetadataProvider.isBadBehaviorMetadata(behaviorMetadata) ||
|
||||
behaviorMetadata.isPrivate()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const behaviorSummary: BehaviorSummary = {
|
||||
name: behaviorMetadata.getName(),
|
||||
fullName: behaviorMetadata.getFullName(),
|
||||
description: behaviorMetadata.getDescription(),
|
||||
actions: generateInstructionsSummaries({
|
||||
instructionsMetadata: behaviorMetadata.getAllActions(),
|
||||
}),
|
||||
conditions: generateInstructionsSummaries({
|
||||
instructionsMetadata: behaviorMetadata.getAllConditions(),
|
||||
}),
|
||||
expressions: [
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: behaviorMetadata.getAllExpressions(),
|
||||
}),
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: behaviorMetadata.getAllStrExpressions(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
if (behaviorMetadata.getObjectType()) {
|
||||
behaviorSummary.objectType = behaviorMetadata.getObjectType();
|
||||
}
|
||||
|
||||
behaviors[behaviorType] = behaviorSummary;
|
||||
});
|
||||
|
||||
return {
|
||||
extensionName: extension.getName(),
|
||||
extensionFullName: extension.getFullName(),
|
||||
description: extension.getDescription(),
|
||||
freeActions: generateInstructionsSummaries({
|
||||
instructionsMetadata: extension.getAllActions(),
|
||||
}),
|
||||
freeConditions: generateInstructionsSummaries({
|
||||
instructionsMetadata: extension.getAllConditions(),
|
||||
}),
|
||||
freeExpressions: [
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: extension.getAllExpressions(),
|
||||
}),
|
||||
...generateExpressionSummaries({
|
||||
expressionsMetadata: extension.getAllStrExpressions(),
|
||||
}),
|
||||
],
|
||||
objects,
|
||||
behaviors,
|
||||
};
|
||||
};
|
@@ -0,0 +1,370 @@
|
||||
// @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>,
|
||||
|};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return simplifiedObject;
|
||||
};
|
||||
|
||||
const getSimplifiedObjectsJson = (
|
||||
objects: gdObjectsContainer
|
||||
): Array<SimplifiedObject> => {
|
||||
return mapFor(0, objects.getObjectsCount(), i => {
|
||||
const object = objects.getObjectAt(i);
|
||||
return getSimplifiedObject(object);
|
||||
});
|
||||
};
|
||||
|
||||
const getSimplifiedObjectGroups = (
|
||||
objectGroups: gdObjectGroupsContainer,
|
||||
objectsContainersList: gdObjectsContainersList
|
||||
): Array<SimplifiedObjectGroup> => {
|
||||
return mapFor(0, objectGroups.count(), i => {
|
||||
const objectGroup = objectGroups.getAt(i);
|
||||
|
||||
const behaviorNames = objectsContainersList
|
||||
.getBehaviorsOfObject(objectGroup.getName(), true)
|
||||
.toJSArray();
|
||||
|
||||
const variablesContainer = gd.ObjectVariableHelper.mergeVariableContainers(
|
||||
objectsContainersList,
|
||||
objectGroup
|
||||
);
|
||||
|
||||
return {
|
||||
objectGroupName: objectGroup.getName(),
|
||||
objectGroupType: objectsContainersList.getTypeOfObject(
|
||||
objectGroup.getName()
|
||||
),
|
||||
objectNames: objectGroup.getAllObjectsNames().toJSArray(),
|
||||
behaviors:
|
||||
behaviorNames.length > 0
|
||||
? behaviorNames.map(behaviorName => ({
|
||||
behaviorName,
|
||||
behaviorType: objectsContainersList.getTypeOfBehaviorInObjectOrGroup(
|
||||
objectGroup.getName(),
|
||||
behaviorName,
|
||||
true
|
||||
),
|
||||
}))
|
||||
: undefined,
|
||||
variables:
|
||||
variablesContainer.count() > 0
|
||||
? getSimplifiedVariablesContainerJson(variablesContainer)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getInstancesDescription = (scene: gdLayout): string => {
|
||||
let isEmpty = true;
|
||||
const instancesCountPerLayer: { [string]: { [string]: number } } = {};
|
||||
|
||||
const instancesListerFunctor = new gd.InitialInstanceJSFunctor();
|
||||
// $FlowFixMe - invoke is not writable
|
||||
instancesListerFunctor.invoke = instancePtr => {
|
||||
// $FlowFixMe - wrapPointer is not exposed
|
||||
const instance: gdInitialInstance = gd.wrapPointer(
|
||||
instancePtr,
|
||||
gd.InitialInstance
|
||||
);
|
||||
const name = instance.getObjectName();
|
||||
if (!name) return;
|
||||
|
||||
const layer = instance.getLayer();
|
||||
|
||||
const layerInstancesCount = (instancesCountPerLayer[layer] =
|
||||
instancesCountPerLayer[layer] || {});
|
||||
layerInstancesCount[name] = (layerInstancesCount[name] || 0) + 1;
|
||||
isEmpty = false;
|
||||
};
|
||||
// $FlowFixMe - JSFunctor is incompatible with Functor
|
||||
scene.getInitialInstances().iterateOverInstances(instancesListerFunctor);
|
||||
instancesListerFunctor.delete();
|
||||
|
||||
if (isEmpty) {
|
||||
return 'There are no instances of objects placed on the scene - the scene is empty.';
|
||||
}
|
||||
|
||||
const layersContainer = scene.getLayers();
|
||||
|
||||
return [
|
||||
`On the scene, there are:`,
|
||||
...mapFor(0, layersContainer.getLayersCount(), i => {
|
||||
const layer = layersContainer.getLayerAt(i);
|
||||
const layerName = layer.getName();
|
||||
const layerInstancesCount = instancesCountPerLayer[layerName];
|
||||
|
||||
return [
|
||||
layerName ? `- on layer "${layer.getName()}":` : `- on base layer:`,
|
||||
!layerInstancesCount || Object.keys(layerInstancesCount).length === 0
|
||||
? ` - Nothing (no instances)`
|
||||
: Object.keys(layerInstancesCount)
|
||||
.map(name => ` - ${layerInstancesCount[name]} ${name}`)
|
||||
.join('\n'),
|
||||
].join('\n');
|
||||
}),
|
||||
'',
|
||||
`Inspect instances on the scene to get more details if needed.`,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const getSimplifiedScene = (
|
||||
project: gdProject,
|
||||
scene: gdLayout
|
||||
): SimplifiedScene => {
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProjectAndLayout(
|
||||
project,
|
||||
scene
|
||||
);
|
||||
|
||||
return {
|
||||
sceneName: scene.getName(),
|
||||
objects: getSimplifiedObjectsJson(scene.getObjects()),
|
||||
objectGroups: getSimplifiedObjectGroups(
|
||||
scene.getObjects().getObjectGroups(),
|
||||
projectScopedContainers.getObjectsContainersList()
|
||||
),
|
||||
sceneVariables: getSimplifiedVariablesContainerJson(scene.getVariables()),
|
||||
instancesOnSceneDescription: getInstancesDescription(scene),
|
||||
};
|
||||
};
|
||||
|
||||
const getSimplifiedProject = (
|
||||
project: gdProject,
|
||||
options: SimplifiedProjectOptions
|
||||
): SimplifiedProject => {
|
||||
const globalObjects = getSimplifiedObjectsJson(project.getObjects());
|
||||
const scenes = mapFor(0, project.getLayoutsCount(), i => {
|
||||
const scene = project.getLayoutAt(i);
|
||||
if (options.scopeToScene && scene.getName() !== options.scopeToScene)
|
||||
return null;
|
||||
|
||||
return getSimplifiedScene(project, scene);
|
||||
}).filter(Boolean);
|
||||
|
||||
const projectScopedContainers = gd.ProjectScopedContainers.makeNewProjectScopedContainersForProject(
|
||||
project
|
||||
);
|
||||
|
||||
// Filter extensions to only include extensions from the project.
|
||||
|
||||
const simplifiedProject: SimplifiedProject = {
|
||||
globalObjects,
|
||||
globalObjectGroups: getSimplifiedObjectGroups(
|
||||
project.getObjects().getObjectGroups(),
|
||||
projectScopedContainers.getObjectsContainersList()
|
||||
),
|
||||
scenes,
|
||||
globalVariables: getSimplifiedVariablesContainerJson(
|
||||
project.getVariables()
|
||||
),
|
||||
};
|
||||
|
||||
return simplifiedProject;
|
||||
};
|
||||
|
||||
const getProjectSpecificExtensionsSummary = (
|
||||
project: gdProject
|
||||
): ProjectSpecificExtensionsSummary => {
|
||||
const startTime = Date.now();
|
||||
const platform = project.getCurrentPlatform();
|
||||
const allExtensions = platform.getAllPlatformExtensions();
|
||||
|
||||
const projectExtensionNames = new Set(
|
||||
mapFor(0, project.getEventsFunctionsExtensionsCount(), i => {
|
||||
const extension = project.getEventsFunctionsExtensionAt(i);
|
||||
return extension.getName();
|
||||
})
|
||||
);
|
||||
|
||||
const projectSpecificExtensions: Array<gdPlatformExtension> = mapVector(
|
||||
allExtensions,
|
||||
extension => {
|
||||
if (projectExtensionNames.has(extension.getName())) {
|
||||
return extension;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
).filter(Boolean);
|
||||
|
||||
const extensionsSummary: ProjectSpecificExtensionsSummary = {
|
||||
extensionSummaries: projectSpecificExtensions.map(extension =>
|
||||
buildExtensionSummary({ gd, extension })
|
||||
),
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.info(
|
||||
`Project specific extensions summary generated in ${duration.toFixed(
|
||||
0
|
||||
)}ms`
|
||||
);
|
||||
|
||||
return extensionsSummary;
|
||||
};
|
||||
|
||||
return { getSimplifiedProject, getProjectSpecificExtensionsSummary };
|
||||
};
|
@@ -0,0 +1,905 @@
|
||||
// @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 {
|
||||
"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();
|
||||
});
|
||||
});
|
1774
newIDE/app/src/EditorFunctions/index.js
Normal file
1774
newIDE/app/src/EditorFunctions/index.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,8 @@ import Text from '../UI/Text';
|
||||
import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import ThreeDotsMenu from '../UI/CustomSvgIcons/ThreeDotsMenu';
|
||||
import Add from '../UI/CustomSvgIcons/Add';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -28,7 +28,8 @@ import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import DropIndicator from '../UI/SortableVirtualizedItemList/DropIndicator';
|
||||
import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget';
|
||||
import useForceUpdate from '../Utils/UseForceUpdate';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -27,7 +27,8 @@ import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext';
|
||||
import DropIndicator from '../UI/SortableVirtualizedItemList/DropIndicator';
|
||||
import { makeDragSourceAndDropTarget } from '../UI/DragAndDrop/DragSourceAndDropTarget';
|
||||
import useForceUpdate from '../Utils/UseForceUpdate';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -29,7 +29,8 @@ import { DragHandleIcon } from '../../UI/DragHandle';
|
||||
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
|
||||
import DropIndicator from '../../UI/SortableVirtualizedItemList/DropIndicator';
|
||||
import { makeDragSourceAndDropTarget } from '../../UI/DragAndDrop/DragSourceAndDropTarget';
|
||||
import Clipboard, { SafeExtractor } from '../../Utils/Clipboard';
|
||||
import Clipboard from '../../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -5,7 +5,8 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -5,7 +5,8 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -5,7 +5,8 @@ import { Trans } from '@lingui/macro';
|
||||
|
||||
import * as React from 'react';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
|
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import Clipboard, { SafeExtractor } from '../Utils/Clipboard';
|
||||
import Clipboard from '../Utils/Clipboard';
|
||||
import { SafeExtractor } from '../Utils/SafeExtractor';
|
||||
import {
|
||||
type SelectionState,
|
||||
getSelectedEvents,
|
||||
|
@@ -219,8 +219,6 @@ const Instruction = (props: Props) => {
|
||||
resourcesManager,
|
||||
scope,
|
||||
} = props;
|
||||
const projectScopedContainers = props.projectScopedContainersAccessor.get();
|
||||
|
||||
const instrFormatter = React.useMemo(
|
||||
() => gd.InstructionSentenceFormatter.get(),
|
||||
[]
|
||||
@@ -244,6 +242,13 @@ const Instruction = (props: Props) => {
|
||||
metadata: gdInstructionMetadata,
|
||||
i18n: I18nType
|
||||
) => {
|
||||
// Keep the call to `projectScopedContainersAccessor.get()` inside this function
|
||||
// (and generally speaking, just before usage of `projectScopedContainers`).
|
||||
// If it's called outside, the function could use a `projectScopedContainers`
|
||||
// that would have been replaced in memory by the one for another instruction.
|
||||
// (because `gd.ProjectScopedContainers.makeNewProjectScopedContainersXXX` functions
|
||||
// are always returning the same object in memory).
|
||||
const projectScopedContainers = props.projectScopedContainersAccessor.get();
|
||||
const { instruction, disabled, renderObjectThumbnail } = props;
|
||||
const formattedTexts = instrFormatter.getAsFormattedText(
|
||||
instruction,
|
||||
|
@@ -0,0 +1,254 @@
|
||||
// @flow
|
||||
import { makeTestProject } from '../../../fixtures/TestProject';
|
||||
import { unserializeFromJSObject } from '../../../Utils/Serializer';
|
||||
import { renderNonTranslatedEventsAsText } from '.';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
describe('EventsTree/TextRenderer', () => {
|
||||
it('renders events as text', () => {
|
||||
const { project } = makeTestProject(gd);
|
||||
try {
|
||||
const serializedEvents = [
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [
|
||||
{
|
||||
type: {
|
||||
value: 'BuiltinCommonInstructions::And',
|
||||
},
|
||||
parameters: [],
|
||||
subInstructions: [
|
||||
{
|
||||
type: {
|
||||
inverted: true,
|
||||
value: 'PlatformBehavior::IsFalling',
|
||||
},
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: {
|
||||
inverted: true,
|
||||
value: 'PlatformBehavior::IsFalling',
|
||||
},
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: {
|
||||
value: 'BuiltinCommonInstructions::And',
|
||||
},
|
||||
parameters: [],
|
||||
subInstructions: [],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Cache' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
{
|
||||
type: { value: 'ThisActionDoesNotExist' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::While',
|
||||
whileConditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Repeat',
|
||||
repeatExpression: '3 + 4',
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Group',
|
||||
name: 'My super group',
|
||||
events: [
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [
|
||||
{
|
||||
type: { value: 'PlatformBehavior::IsFalling' },
|
||||
parameters: [
|
||||
'GroupOfSpriteObjectsWithBehaviors',
|
||||
'PlatformerObject',
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: { value: 'ChangeAnimation' },
|
||||
parameters: ['MySpriteObject', '=', '1'],
|
||||
},
|
||||
{
|
||||
type: { value: 'Montre' },
|
||||
parameters: ['GroupOfObjects', ''],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'BuiltinCommonInstructions::Standard',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const eventsList = new gd.EventsList();
|
||||
unserializeFromJSObject(
|
||||
eventsList,
|
||||
serializedEvents,
|
||||
'unserializeFrom',
|
||||
project
|
||||
);
|
||||
|
||||
expect(
|
||||
renderNonTranslatedEventsAsText({
|
||||
eventsList,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
"<event-0>
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
Sub-events:
|
||||
<event-0.0>
|
||||
Conditions:
|
||||
- If all of these conditions are true:
|
||||
- (inverted) GroupOfSpriteObjectsWithBehaviors is falling
|
||||
- (inverted) GroupOfSpriteObjectsWithBehaviors is falling
|
||||
- If all of these conditions are true:
|
||||
(no conditions)
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Hide GroupOfObjects
|
||||
- Unknown or unsupported instruction
|
||||
</event-0.0>
|
||||
<event-0.1>
|
||||
While these conditions are true:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Then do:
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
</event-0.1>
|
||||
<event-0.2>
|
||||
Repeat \`3 + 4\` times these:
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
</event-0.2>
|
||||
<event-0.3>
|
||||
Group called \\"My super group\\":
|
||||
Sub-events:
|
||||
<event-0.3.0>
|
||||
Conditions:
|
||||
- GroupOfSpriteObjectsWithBehaviors is falling
|
||||
Actions:
|
||||
- Change the number of the animation of MySpriteObject: = 1
|
||||
- Show GroupOfObjects
|
||||
</event-0.3.0>
|
||||
<event-0.3.1>
|
||||
Conditions:
|
||||
(no conditions)
|
||||
Actions:
|
||||
(no actions)
|
||||
</event-0.3.1>
|
||||
</event-0.3>
|
||||
</event-0>"
|
||||
`);
|
||||
} finally {
|
||||
project.delete();
|
||||
}
|
||||
});
|
||||
});
|
275
newIDE/app/src/EventsSheet/EventsTree/TextRenderer/index.js
Normal file
275
newIDE/app/src/EventsSheet/EventsTree/TextRenderer/index.js
Normal file
@@ -0,0 +1,275 @@
|
||||
// @flow
|
||||
|
||||
import { mapFor } from '../../../Utils/MapFor';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
const renderInstructionsAsText = ({
|
||||
instructionsList,
|
||||
padding,
|
||||
areConditions,
|
||||
}: {|
|
||||
instructionsList: gdInstructionsList,
|
||||
padding: string,
|
||||
areConditions: boolean,
|
||||
|}) => {
|
||||
const renderInstruction = (instruction: gdInstruction) => {
|
||||
const invertedText = instruction.isInverted() ? '(inverted) ' : '';
|
||||
const metadata = areConditions
|
||||
? gd.MetadataProvider.getConditionMetadata(
|
||||
gd.JsPlatform.get(),
|
||||
instruction.getType()
|
||||
)
|
||||
: gd.MetadataProvider.getActionMetadata(
|
||||
gd.JsPlatform.get(),
|
||||
instruction.getType()
|
||||
);
|
||||
|
||||
const formattedTexts = gd.InstructionSentenceFormatter.get().getAsFormattedText(
|
||||
instruction,
|
||||
metadata
|
||||
);
|
||||
|
||||
const sentence = mapFor(0, formattedTexts.size(), i => {
|
||||
const value = formattedTexts.getString(i);
|
||||
return value;
|
||||
}).join('');
|
||||
|
||||
return {
|
||||
text: `${padding}- ${[invertedText, sentence].filter(Boolean).join('')}`,
|
||||
canHaveSubInstructions: metadata.canHaveSubInstructions(),
|
||||
};
|
||||
};
|
||||
|
||||
if (instructionsList.size() === 0) {
|
||||
return areConditions
|
||||
? `${padding}(no conditions)`
|
||||
: `${padding}(no actions)`;
|
||||
}
|
||||
|
||||
return mapFor(0, instructionsList.size(), i => {
|
||||
const instruction = instructionsList.get(i);
|
||||
const { text, canHaveSubInstructions } = renderInstruction(instruction);
|
||||
|
||||
const subInstructionsText = canHaveSubInstructions
|
||||
? renderInstructionsAsText({
|
||||
instructionsList: instruction.getSubInstructions(),
|
||||
padding: padding + ' ',
|
||||
areConditions,
|
||||
})
|
||||
: '';
|
||||
|
||||
return [text, subInstructionsText].filter(Boolean).join('\n');
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
const eventsTextRenderers: {
|
||||
[string]: ({|
|
||||
event: gdBaseEvent,
|
||||
padding: string,
|
||||
|}) => string,
|
||||
} = {
|
||||
'BuiltinCommonInstructions::Standard': ({ event, padding }) => {
|
||||
const standardEvent = gd.asStandardEvent(event);
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: standardEvent.getConditions(),
|
||||
padding: padding,
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: standardEvent.getActions(),
|
||||
padding: padding,
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Comment': ({ event, padding }) => {
|
||||
return `${padding}(comment - content is not displayed)`;
|
||||
},
|
||||
'BuiltinCommonInstructions::While': ({ event, padding }) => {
|
||||
const whileEvent = gd.asWhileEvent(event);
|
||||
const whileConditions = renderInstructionsAsText({
|
||||
instructionsList: whileEvent.getWhileConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: whileEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: whileEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}While these conditions are true:
|
||||
${whileConditions}
|
||||
${padding}Then do:
|
||||
${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Repeat': ({ event, padding }) => {
|
||||
const repeatEvent = gd.asRepeatEvent(event);
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: repeatEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: repeatEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}Repeat \`${repeatEvent
|
||||
.getRepeatExpression()
|
||||
.getPlainString()}\` times these:
|
||||
${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::ForEach': ({ event, padding }) => {
|
||||
const forEachEvent = gd.asForEachEvent(event);
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: forEachEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: forEachEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}Repeat these separately for each instance of ${forEachEvent.getObjectToPick()}:
|
||||
${padding}Conditions:
|
||||
${conditions}
|
||||
${padding}Actions:
|
||||
${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::ForEachChildVariable': ({ event, padding }) => {
|
||||
const forEachChildVariableEvent = gd.asForEachChildVariableEvent(event);
|
||||
const valueIteratorName = forEachChildVariableEvent.getValueIteratorVariableName();
|
||||
const keyIteratorName = forEachChildVariableEvent.getKeyIteratorVariableName();
|
||||
const iterableName = forEachChildVariableEvent.getIterableVariableName();
|
||||
const conditions = renderInstructionsAsText({
|
||||
instructionsList: forEachChildVariableEvent.getConditions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: true,
|
||||
});
|
||||
const actions = renderInstructionsAsText({
|
||||
instructionsList: forEachChildVariableEvent.getActions(),
|
||||
padding: padding + ' ',
|
||||
areConditions: false,
|
||||
});
|
||||
|
||||
return `${padding}For each child in \`${iterableName ||
|
||||
'(no variable chosen yet)'}\`, store the child in variable \`${valueIteratorName ||
|
||||
'(ignored)'}\`, the child name in \`${keyIteratorName ||
|
||||
'(ignored)'}\` and do:
|
||||
${padding}Conditions:
|
||||
${padding}${conditions}
|
||||
${padding}Actions:
|
||||
${padding}${actions}`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Group': ({ event, padding }) => {
|
||||
const groupEvent = gd.asGroupEvent(event);
|
||||
return `${padding}Group called "${groupEvent.getName()}":`;
|
||||
},
|
||||
'BuiltinCommonInstructions::Link': ({ event, padding }) => {
|
||||
return `${padding}(link to events in events sheet called "${event.getTarget()}")`;
|
||||
},
|
||||
};
|
||||
|
||||
const renderEventAsText = ({
|
||||
event,
|
||||
padding,
|
||||
eventPath,
|
||||
}: {|
|
||||
event: gdBaseEvent,
|
||||
padding: string,
|
||||
eventPath: string,
|
||||
|}) => {
|
||||
const isDisabled = event.isDisabled();
|
||||
if (isDisabled) return `${padding}(This event is disabled - ignored)`;
|
||||
|
||||
const textRenderer = eventsTextRenderers[event.getType()];
|
||||
const eventText = textRenderer
|
||||
? textRenderer({ event, padding })
|
||||
: `${padding}(This event is unknown/unsupported - ignored)`;
|
||||
|
||||
let subEvents = '';
|
||||
if (event.canHaveSubEvents()) {
|
||||
subEvents = renderEventsAsText({
|
||||
eventsList: event.getSubEvents(),
|
||||
parentPath: eventPath,
|
||||
padding: padding + ' ',
|
||||
});
|
||||
}
|
||||
|
||||
return eventText + (subEvents ? `\n${padding}Sub-events:\n${subEvents}` : '');
|
||||
};
|
||||
|
||||
export const renderEventsAsText = ({
|
||||
eventsList,
|
||||
parentPath,
|
||||
padding,
|
||||
}: {|
|
||||
eventsList: gdEventsList,
|
||||
parentPath: string,
|
||||
padding: string,
|
||||
|}): string => {
|
||||
return mapFor(0, eventsList.getEventsCount(), i => {
|
||||
const event = eventsList.getEventAt(i);
|
||||
|
||||
const eventPath = (parentPath ? parentPath + '.' : '') + i;
|
||||
const eventAndSubEventsText = renderEventAsText({
|
||||
event,
|
||||
eventPath,
|
||||
padding: padding + ' ',
|
||||
});
|
||||
|
||||
return `${padding}<event-${eventPath}>
|
||||
${eventAndSubEventsText}
|
||||
${padding}</event-${eventPath}>`;
|
||||
}).join('\n');
|
||||
};
|
||||
|
||||
export const renderNonTranslatedEventsAsText = ({
|
||||
eventsList,
|
||||
}: {
|
||||
eventsList: gdEventsList,
|
||||
}) => {
|
||||
// Temporarily override the getTranslation function to return the original
|
||||
// string, so that events are always rendered in English.
|
||||
// $FlowFixMe
|
||||
const previousGetTranslation = gd.getTranslation;
|
||||
// $FlowFixMe
|
||||
gd.getTranslation = (str: string) => str;
|
||||
|
||||
let text = '';
|
||||
try {
|
||||
text = renderEventsAsText({
|
||||
eventsList,
|
||||
parentPath: '',
|
||||
padding: '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error while rendering events as text:', error);
|
||||
text = 'Error while rendering events as text.';
|
||||
} finally {
|
||||
// $FlowFixMe
|
||||
gd.getTranslation = previousGetTranslation;
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
@@ -136,10 +136,6 @@ type EventsContainerProps = {|
|
||||
idPrefix: string,
|
||||
|};
|
||||
|
||||
const hiddenBecauseHeightNotComputedYetStyle = {
|
||||
visibility: 'hidden',
|
||||
};
|
||||
|
||||
/**
|
||||
* The component containing an event.
|
||||
* It will report the rendered event height so that the EventsTree can
|
||||
@@ -161,7 +157,8 @@ const EventContainer = (props: EventsContainerProps) => {
|
||||
|
||||
// At EACH rendering, update the cache with the current height of the event.
|
||||
React.useLayoutEffect(() => {
|
||||
const height = containerRef.current ? containerRef.current.offsetHeight : 0;
|
||||
const container = containerRef.current;
|
||||
const height = container ? container.offsetHeight : 0;
|
||||
if (height === 0) {
|
||||
// An empty height means that the event is hidden, when navigating outside of the events sheet tab for example.
|
||||
// Don't store the height in this case.
|
||||
@@ -204,11 +201,6 @@ const EventContainer = (props: EventsContainerProps) => {
|
||||
onClick={props.onEventClick}
|
||||
onContextMenu={_onEventContextMenu}
|
||||
{...longTouchForContextMenuProps}
|
||||
style={
|
||||
eventsHeightsCache.getEventHeight(event)
|
||||
? undefined
|
||||
: hiddenBecauseHeightNotComputedYetStyle
|
||||
}
|
||||
>
|
||||
{!!EventComponent && (
|
||||
<div style={styles.eventComponentContainer}>
|
||||
|
@@ -352,6 +352,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 +1465,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 +1524,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();
|
||||
}
|
||||
);
|
||||
@@ -2188,6 +2217,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 +2226,7 @@ const EventsSheet = (props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
updateToolbar,
|
||||
onResourceExternallyChanged,
|
||||
onEventsModifiedOutsideEditor,
|
||||
}));
|
||||
|
||||
const component = React.useRef<?EventsSheetComponentWithoutHandle>(null);
|
||||
@@ -2206,6 +2237,9 @@ const EventsSheet = (props, ref) => {
|
||||
if (component.current)
|
||||
component.current.onResourceExternallyChanged(resourceInfo);
|
||||
};
|
||||
const onEventsModifiedOutsideEditor = () => {
|
||||
if (component.current) component.current.onEventsModifiedOutsideEditor();
|
||||
};
|
||||
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const preferences = React.useContext(PreferencesContext);
|
||||
|
@@ -858,7 +858,7 @@ export const LeaderboardAdmin = ({
|
||||
helperMarkdownText={
|
||||
currentLeaderboard.playerUnicityDisplayChoice === 'FREE'
|
||||
? i18n._(
|
||||
t`Users can chose to see only players' best entries or not.`
|
||||
t`Users can choose to see only players' best entries or not.`
|
||||
)
|
||||
: currentLeaderboard.playerUnicityDisplayChoice ===
|
||||
'PREFER_UNIQUE'
|
||||
|
@@ -1,536 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import {
|
||||
ColumnStackLayout,
|
||||
ResponsiveLineStackLayout,
|
||||
} from '../../../UI/Layout';
|
||||
import Text from '../../../UI/Text';
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { type AiRequest } from '../../../Utils/GDevelopServices/Generation';
|
||||
import RaisedButton from '../../../UI/RaisedButton';
|
||||
import { CompactTextAreaField } from '../../../UI/CompactTextAreaField';
|
||||
import { Column, Line } from '../../../UI/Grid';
|
||||
import LeftLoader from '../../../UI/LeftLoader';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { ChatMarkdownText } from './ChatMarkdownText';
|
||||
import ScrollView, { type ScrollViewInterface } from '../../../UI/ScrollView';
|
||||
import AlertMessage from '../../../UI/AlertMessage';
|
||||
import classes from './AiRequestChat.module.css';
|
||||
import RobotIcon from '../../../ProjectCreation/RobotIcon';
|
||||
import { useResponsiveWindowSize } from '../../../UI/Responsive/ResponsiveWindowMeasurer';
|
||||
import GetSubscriptionCard from '../../../Profile/Subscription/GetSubscriptionCard';
|
||||
import { type Quota } from '../../../Utils/GDevelopServices/Usage';
|
||||
import IconButton from '../../../UI/IconButton';
|
||||
import Like from '../../../UI/CustomSvgIcons/Like';
|
||||
import Dislike from '../../../UI/CustomSvgIcons/Dislike';
|
||||
import Copy from '../../../UI/CustomSvgIcons/Copy';
|
||||
import GDevelopThemeContext from '../../../UI/Theme/GDevelopThemeContext';
|
||||
import { type MessageDescriptor } from '../../../Utils/i18n/MessageDescriptor.flow';
|
||||
import Link from '../../../UI/Link';
|
||||
import { getHelpLink } from '../../../Utils/HelpLink';
|
||||
import Window from '../../../Utils/Window';
|
||||
import { DislikeFeedbackDialog } from './DislikeFeedbackDialog';
|
||||
|
||||
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
|
||||
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
|
||||
|
||||
type Props = {
|
||||
aiRequest: AiRequest | null,
|
||||
|
||||
isLaunchingAiRequest: boolean,
|
||||
onSendUserRequest: string => Promise<void>,
|
||||
onSendFeedback: (
|
||||
aiRequestId: string,
|
||||
messageIndex: number,
|
||||
feedback: 'like' | 'dislike',
|
||||
reason?: string
|
||||
) => Promise<void>,
|
||||
hasOpenedProject: boolean,
|
||||
|
||||
// Error that occurred while sending the last request.
|
||||
lastSendError: ?Error,
|
||||
|
||||
// Quota available for using the feature.
|
||||
quota: Quota | null,
|
||||
increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none',
|
||||
aiRequestPriceInCredits: number | null,
|
||||
availableCredits: number,
|
||||
};
|
||||
|
||||
export type AiRequestChatInterface = {|
|
||||
resetUserInput: () => void,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
chatBubble: {
|
||||
paddingTop: 5,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
};
|
||||
|
||||
type ChatBubbleProps = {|
|
||||
children: React.Node,
|
||||
feedbackButtons?: React.Node,
|
||||
role: 'assistant' | 'user',
|
||||
|};
|
||||
|
||||
const ChatBubble = ({ children, feedbackButtons, role }: ChatBubbleProps) => {
|
||||
return (
|
||||
<div className={classes.chatBubbleContainer}>
|
||||
<Paper
|
||||
background={role === 'user' ? 'light' : 'medium'}
|
||||
style={styles.chatBubble}
|
||||
>
|
||||
<div className={classes.chatBubbleContent}>{children}</div>
|
||||
{feedbackButtons}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
|
||||
(
|
||||
{
|
||||
aiRequest,
|
||||
isLaunchingAiRequest,
|
||||
onSendUserRequest,
|
||||
onSendFeedback,
|
||||
quota,
|
||||
increaseQuotaOffering,
|
||||
lastSendError,
|
||||
aiRequestPriceInCredits,
|
||||
availableCredits,
|
||||
hasOpenedProject,
|
||||
}: Props,
|
||||
ref
|
||||
) => {
|
||||
const [userRequestText, setUserRequestText] = React.useState('');
|
||||
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
|
||||
const [messageFeedbacks, setMessageFeedbacks] = React.useState({});
|
||||
const theme = React.useContext(GDevelopThemeContext);
|
||||
const [
|
||||
dislikeFeedbackDialogOpenedFor,
|
||||
setDislikeFeedbackDialogOpenedFor,
|
||||
] = React.useState(null);
|
||||
|
||||
const [newChatPlaceholder] = React.useState(() => {
|
||||
const newChatPlaceholders: Array<MessageDescriptor> = [
|
||||
t`How to add a leaderboard?`,
|
||||
t`How to display the health of my player?`,
|
||||
t`How to add an explosion when an enemy is destroyed?`,
|
||||
t`How to create a main menu for my game?`,
|
||||
...(hasOpenedProject
|
||||
? [
|
||||
t`What would you add to my game?`,
|
||||
t`How to make my game more fun?`,
|
||||
t`What is a good GDevelop feature I could use in my game?`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return newChatPlaceholders[
|
||||
Math.floor(Math.random() * newChatPlaceholders.length)
|
||||
];
|
||||
});
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
resetUserInput: () => {
|
||||
setUserRequestText('');
|
||||
if (scrollViewRef.current) {
|
||||
scrollViewRef.current.scrollToBottom({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { isMobile } = useResponsiveWindowSize();
|
||||
|
||||
const quotaOrCreditsExplanation = !quota ? null /* User is probably not even logged in. */ : !quota.limitReached ? (
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
You still have {quota.max - quota.current} free answers from the AI.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You still have {quota.max - quota.current} free answers with your
|
||||
membership (refreshed every month).
|
||||
</Trans>
|
||||
)
|
||||
) : aiRequestPriceInCredits ? (
|
||||
availableCredits ? (
|
||||
<Trans>
|
||||
Use an AI request for <b>{aiRequestPriceInCredits} credits</b> – you
|
||||
have {availableCredits} credits.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Use an AI request for <b>{aiRequestPriceInCredits} credits.</b>
|
||||
</Trans>
|
||||
)
|
||||
) : null;
|
||||
|
||||
const subscriptionBanner =
|
||||
quota && quota.limitReached && increaseQuotaOffering !== 'none' ? (
|
||||
<GetSubscriptionCard
|
||||
subscriptionDialogOpeningReason={
|
||||
increaseQuotaOffering === 'subscribe'
|
||||
? 'AI requests (subscribe)'
|
||||
: 'AI requests (upgrade)'
|
||||
}
|
||||
label={
|
||||
increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>Get GDevelop premium</Trans>
|
||||
) : (
|
||||
<Trans>Upgrade</Trans>
|
||||
)
|
||||
}
|
||||
recommendedPlanIdIfNoSubscription="gdevelop_gold"
|
||||
canHide
|
||||
>
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
<Text noMargin>
|
||||
{increaseQuotaOffering === 'subscribe' ? (
|
||||
<Trans>
|
||||
Unlock AI requests included with a GDevelop premium plan.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Get even more AI requests included with a higher premium
|
||||
plan.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Line>
|
||||
</GetSubscriptionCard>
|
||||
) : null;
|
||||
|
||||
const errorOrQuotaOrCreditsExplanation = (
|
||||
<Text size="body2" color={lastSendError ? 'error' : 'secondary'}>
|
||||
{lastSendError ? (
|
||||
<Trans>
|
||||
An error happened when sending your request, please try again.
|
||||
</Trans>
|
||||
) : (
|
||||
quotaOrCreditsExplanation || '\u00a0'
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (!aiRequest) {
|
||||
return (
|
||||
<div className={classes.newChatContainer}>
|
||||
<ColumnStackLayout justifyContent="center" expand>
|
||||
<Line noMargin justifyContent="center">
|
||||
<RobotIcon rotating size={40} />
|
||||
</Line>
|
||||
<Column noMargin alignItems="center">
|
||||
<Text size="bold-title">
|
||||
<Trans>What do you want to make?</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout justifyContent="center" noMargin>
|
||||
<Column noMargin alignItems="stretch" justifyContent="stretch">
|
||||
<CompactTextAreaField
|
||||
maxLength={6000}
|
||||
value={userRequestText}
|
||||
disabled={isLaunchingAiRequest}
|
||||
onChange={userRequestText =>
|
||||
setUserRequestText(userRequestText)
|
||||
}
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
placeholder={newChatPlaceholder}
|
||||
rows={5}
|
||||
/>
|
||||
</Column>
|
||||
<Line noMargin>
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
expand
|
||||
>
|
||||
{!isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<LeftLoader reserveSpace isLoading={isLaunchingAiRequest}>
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
label={<Trans>Send</Trans>}
|
||||
style={{ flexShrink: 0 }}
|
||||
disabled={isLaunchingAiRequest || !userRequestText}
|
||||
onClick={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
/>
|
||||
</LeftLoader>
|
||||
</Line>
|
||||
{isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
</ResponsiveLineStackLayout>
|
||||
</Line>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
{subscriptionBanner}
|
||||
</ColumnStackLayout>
|
||||
<Column justifyContent="center">
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>
|
||||
The AI is experimental and still being improved.{' '}
|
||||
<Link
|
||||
href={getHelpLink('/interface/ask-ai')}
|
||||
color="secondary"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(getHelpLink('/interface/ask-ai'))
|
||||
}
|
||||
>
|
||||
It has access to your game objects but not events.
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text size="body-small" color="secondary" align="center" noMargin>
|
||||
<Trans>Answers may have mistakes: always verify them.</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userMessagesCount = aiRequest.output.filter(
|
||||
message => message.role === 'user'
|
||||
).length;
|
||||
|
||||
return (
|
||||
<ColumnStackLayout
|
||||
expand
|
||||
alignItems="stretch"
|
||||
justifyContent="stretch"
|
||||
useFullHeight
|
||||
>
|
||||
<ScrollView ref={scrollViewRef}>
|
||||
{aiRequest.output.flatMap((message, messageIndex) => {
|
||||
if (message.role === 'user') {
|
||||
return [
|
||||
<Line key={messageIndex} justifyContent="flex-end">
|
||||
<ChatBubble role="user">
|
||||
<ChatMarkdownText
|
||||
source={message.content
|
||||
.map(messageContent => messageContent.text)
|
||||
.join('\n')}
|
||||
/>
|
||||
</ChatBubble>
|
||||
</Line>,
|
||||
];
|
||||
}
|
||||
if (message.role === 'assistant') {
|
||||
return [
|
||||
...message.content
|
||||
.map((messageContent, messageContentIndex) => {
|
||||
const key = `messageIndex${messageIndex}-${messageContentIndex}`;
|
||||
if (messageContent.type === 'output_text') {
|
||||
const feedbackKey = `${messageIndex}-${messageContentIndex}`;
|
||||
const currentFeedback = messageFeedbacks[feedbackKey];
|
||||
|
||||
return (
|
||||
<Line key={key} justifyContent="flex-start">
|
||||
<ChatBubble
|
||||
role="assistant"
|
||||
feedbackButtons={
|
||||
<div className={classes.feedbackButtonsContainer}>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`Copy`}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
messageContent.text
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`This was helpful`}
|
||||
onClick={() => {
|
||||
setMessageFeedbacks({
|
||||
...messageFeedbacks,
|
||||
[feedbackKey]: 'like',
|
||||
});
|
||||
onSendFeedback(
|
||||
aiRequest.id,
|
||||
messageIndex,
|
||||
'like'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Like
|
||||
fontSize="small"
|
||||
htmlColor={
|
||||
currentFeedback === 'like'
|
||||
? theme.message.valid
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
tooltip={t`This needs improvement`}
|
||||
onClick={() => {
|
||||
setMessageFeedbacks({
|
||||
...messageFeedbacks,
|
||||
[feedbackKey]: 'dislike',
|
||||
});
|
||||
setDislikeFeedbackDialogOpenedFor({
|
||||
aiRequestId: aiRequest.id,
|
||||
messageIndex,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Dislike
|
||||
fontSize="small"
|
||||
htmlColor={
|
||||
currentFeedback === 'dislike'
|
||||
? theme.message.warning
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatMarkdownText source={messageContent.text} />
|
||||
</ChatBubble>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
if (messageContent.type === 'reasoning') {
|
||||
return (
|
||||
<Line key={key} justifyContent="flex-start">
|
||||
<ChatBubble role="assistant">
|
||||
<ChatMarkdownText
|
||||
source={messageContent.summary.text}
|
||||
/>
|
||||
</ChatBubble>
|
||||
</Line>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
})}
|
||||
|
||||
{aiRequest.status === 'error' ? (
|
||||
<Line justifyContent="flex-start">
|
||||
<AlertMessage kind="error">
|
||||
<Trans>
|
||||
The AI encountered an error while handling your request - this
|
||||
was request was not counted in your AI usage. Try again later.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
</Line>
|
||||
) : aiRequest.status === 'working' ? (
|
||||
<Line justifyContent="flex-start">
|
||||
<div className={classes.thinkingText}>
|
||||
<LeftLoader isLoading>
|
||||
<Text noMargin displayInlineAsSpan>
|
||||
<Trans>Thinking about your request...</Trans>
|
||||
</Text>
|
||||
</LeftLoader>
|
||||
</div>
|
||||
</Line>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
{userMessagesCount >= TOO_MANY_USER_MESSAGES_WARNING_COUNT ? (
|
||||
<AlertMessage
|
||||
kind={
|
||||
userMessagesCount >= TOO_MANY_USER_MESSAGES_ERROR_COUNT
|
||||
? 'error'
|
||||
: 'warning'
|
||||
}
|
||||
>
|
||||
<Trans>
|
||||
The chat is becoming long - consider creating a new chat to ask
|
||||
other questions. The AI will better analyze your game and request
|
||||
in a new chat.
|
||||
</Trans>
|
||||
</AlertMessage>
|
||||
) : (
|
||||
subscriptionBanner
|
||||
)}
|
||||
<form
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
>
|
||||
<ColumnStackLayout
|
||||
justifyContent="stretch"
|
||||
alignItems="stretch"
|
||||
noMargin
|
||||
>
|
||||
<CompactTextAreaField
|
||||
maxLength={6000}
|
||||
value={userRequestText}
|
||||
disabled={isLaunchingAiRequest}
|
||||
onChange={userRequestText => setUserRequestText(userRequestText)}
|
||||
placeholder={t`Ask a follow up question`}
|
||||
rows={2}
|
||||
onSubmit={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
/>
|
||||
<Column noMargin alignItems="flex-end">
|
||||
<ResponsiveLineStackLayout
|
||||
noMargin
|
||||
alignItems="flex-start"
|
||||
justifyContent="space-between"
|
||||
expand
|
||||
>
|
||||
{!isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
<Line noMargin justifyContent="flex-end">
|
||||
<LeftLoader reserveSpace isLoading={isLaunchingAiRequest}>
|
||||
<RaisedButton
|
||||
color="primary"
|
||||
disabled={aiRequest.status === 'working'}
|
||||
label={<Trans>Send</Trans>}
|
||||
onClick={() => {
|
||||
onSendUserRequest(userRequestText);
|
||||
}}
|
||||
/>
|
||||
</LeftLoader>
|
||||
</Line>
|
||||
{isMobile && errorOrQuotaOrCreditsExplanation}
|
||||
</ResponsiveLineStackLayout>
|
||||
</Column>
|
||||
</ColumnStackLayout>
|
||||
</form>
|
||||
{dislikeFeedbackDialogOpenedFor && (
|
||||
<DislikeFeedbackDialog
|
||||
open
|
||||
onClose={() => setDislikeFeedbackDialogOpenedFor(null)}
|
||||
onSendFeedback={(reason: string) => {
|
||||
onSendFeedback(
|
||||
dislikeFeedbackDialogOpenedFor.aiRequestId,
|
||||
dislikeFeedbackDialogOpenedFor.messageIndex,
|
||||
'dislike',
|
||||
reason
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ColumnStackLayout>
|
||||
);
|
||||
}
|
||||
);
|
@@ -1,317 +0,0 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import { type RenderEditorContainerPropsWithRef } from '../BaseEditor';
|
||||
import { type ObjectWithContext } from '../../../ObjectsList/EnumerateObjects';
|
||||
import Paper from '../../../UI/Paper';
|
||||
import { AiRequestChat, type AiRequestChatInterface } from './AiRequestChat';
|
||||
import {
|
||||
addUserMessageToAiRequest,
|
||||
createAiRequest,
|
||||
getAiRequest,
|
||||
sendAiRequestFeedback,
|
||||
type AiRequest,
|
||||
} from '../../../Utils/GDevelopServices/Generation';
|
||||
import { delay } from '../../../Utils/Delay';
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import { AskAiHistory } from './AskAiHistory';
|
||||
import { getSimplifiedProjectJson } from '../../../Utils/SimplifiedProjectJson';
|
||||
import {
|
||||
canUpgradeSubscription,
|
||||
hasValidSubscriptionPlan,
|
||||
} from '../../../Utils/GDevelopServices/Usage';
|
||||
import { retryIfFailed } from '../../../Utils/RetryIfFailed';
|
||||
import { CreditsPackageStoreContext } from '../../../AssetStore/CreditsPackages/CreditsPackageStoreContext';
|
||||
|
||||
type Props = {|
|
||||
isActive: boolean,
|
||||
project: ?gdProject,
|
||||
setToolbar: (?React.Node) => void,
|
||||
|};
|
||||
|
||||
const styles = {
|
||||
paper: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
chatContainer: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: 'min(100%, 800px)',
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
marginBottom: 10,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export type AskAiEditorInterface = {|
|
||||
getProject: () => void,
|
||||
updateToolbar: () => void,
|
||||
forceUpdateEditor: () => void,
|
||||
onEventsBasedObjectChildrenEdited: () => void,
|
||||
onSceneObjectEdited: (
|
||||
scene: gdLayout,
|
||||
objectWithContext: ObjectWithContext
|
||||
) => void,
|
||||
onSceneObjectsDeleted: (scene: gdLayout) => void,
|
||||
|};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const AskAi = React.memo<Props>(
|
||||
React.forwardRef<Props, AskAiEditorInterface>(
|
||||
({ isActive, setToolbar, project }: Props, ref) => {
|
||||
const [
|
||||
selectedAiRequest,
|
||||
setSelectedAiRequest,
|
||||
] = React.useState<AiRequest | null>(null);
|
||||
const [lastError, setLastError] = React.useState<Error | null>(null);
|
||||
const [isHistoryOpen, setIsHistoryOpen] = React.useState<boolean>(false);
|
||||
|
||||
const onStartNewChat = React.useCallback(() => {
|
||||
setSelectedAiRequest(null);
|
||||
}, []);
|
||||
|
||||
const onOpenHistory = React.useCallback(() => {
|
||||
setIsHistoryOpen(true);
|
||||
}, []);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
setIsHistoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const canStartNewChat = !!selectedAiRequest;
|
||||
const updateToolbar = React.useCallback(
|
||||
() => {
|
||||
if (setToolbar) {
|
||||
setToolbar(
|
||||
<Toolbar
|
||||
onStartNewChat={onStartNewChat}
|
||||
canStartNewChat={canStartNewChat}
|
||||
onOpenHistory={onOpenHistory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[setToolbar, onStartNewChat, canStartNewChat, onOpenHistory]
|
||||
);
|
||||
|
||||
React.useEffect(updateToolbar, [updateToolbar]);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getProject: noop,
|
||||
updateToolbar,
|
||||
forceUpdateEditor: noop,
|
||||
onEventsBasedObjectChildrenEdited: noop,
|
||||
onSceneObjectEdited: noop,
|
||||
onSceneObjectsDeleted: noop,
|
||||
}));
|
||||
|
||||
const aiRequestChatRef = React.useRef<AiRequestChatInterface | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isLaunchingAiRequest, setIsLaunchingAiRequest] = React.useState(
|
||||
false
|
||||
);
|
||||
|
||||
const { openCreditsPackageDialog } = React.useContext(
|
||||
CreditsPackageStoreContext
|
||||
);
|
||||
|
||||
const {
|
||||
profile,
|
||||
getAuthorizationHeader,
|
||||
onOpenCreateAccountDialog,
|
||||
limits,
|
||||
onRefreshLimits,
|
||||
subscription,
|
||||
} = React.useContext(AuthenticatedUserContext);
|
||||
|
||||
const availableCredits = limits ? limits.credits.userBalance.amount : 0;
|
||||
const quota =
|
||||
(limits && limits.quotas && limits.quotas['ai-request']) || null;
|
||||
const aiRequestPriceInCredits =
|
||||
(limits &&
|
||||
limits.credits &&
|
||||
limits.credits.prices['ai-request'] &&
|
||||
limits.credits.prices['ai-request'].priceInCredits) ||
|
||||
null;
|
||||
|
||||
// Refresh limits when navigating ot this tab, as we want to be sure
|
||||
// we display the proper quota and credits information for the user.
|
||||
React.useEffect(
|
||||
() => {
|
||||
if (isActive) {
|
||||
onRefreshLimits();
|
||||
}
|
||||
},
|
||||
[isActive, onRefreshLimits]
|
||||
);
|
||||
|
||||
const selectedAiRequestId = selectedAiRequest
|
||||
? selectedAiRequest.id
|
||||
: null;
|
||||
const sendUserRequest = React.useCallback(
|
||||
async (userRequestText: string) => {
|
||||
if (!profile) {
|
||||
onOpenCreateAccountDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
let payWithCredits = false;
|
||||
if (quota && quota.limitReached && aiRequestPriceInCredits) {
|
||||
payWithCredits = true;
|
||||
if (availableCredits < aiRequestPriceInCredits) {
|
||||
openCreditsPackageDialog({
|
||||
missingCredits: aiRequestPriceInCredits - availableCredits,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const simplifiedProjectJson = project
|
||||
? JSON.stringify(getSimplifiedProjectJson(project))
|
||||
: null;
|
||||
|
||||
try {
|
||||
setIsLaunchingAiRequest(true);
|
||||
setLastError(null);
|
||||
|
||||
let aiRequest;
|
||||
try {
|
||||
// Either create a new ai request or continue the existing one with a new message.
|
||||
aiRequest = selectedAiRequestId
|
||||
? await addUserMessageToAiRequest(getAuthorizationHeader, {
|
||||
aiRequestId: selectedAiRequestId,
|
||||
userId: profile.id,
|
||||
userRequest: userRequestText,
|
||||
simplifiedProjectJson,
|
||||
payWithCredits,
|
||||
})
|
||||
: await createAiRequest(getAuthorizationHeader, {
|
||||
userRequest: userRequestText,
|
||||
userId: profile.id,
|
||||
simplifiedProjectJson,
|
||||
payWithCredits,
|
||||
});
|
||||
} finally {
|
||||
setIsLaunchingAiRequest(false);
|
||||
}
|
||||
setIsLaunchingAiRequest(false);
|
||||
setSelectedAiRequest(aiRequest);
|
||||
if (aiRequestChatRef.current) {
|
||||
aiRequestChatRef.current.resetUserInput();
|
||||
}
|
||||
|
||||
while (aiRequest.status === 'working') {
|
||||
await delay(1000);
|
||||
aiRequest = await getAiRequest(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId: aiRequest.id,
|
||||
});
|
||||
setSelectedAiRequest(aiRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
setLastError(error);
|
||||
}
|
||||
|
||||
// Refresh the user limits, to ensure quota and credits information
|
||||
// is up-to-date after an AI request.
|
||||
await delay(500);
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, onRefreshLimits);
|
||||
} catch (error) {
|
||||
// Ignore limits refresh error.
|
||||
}
|
||||
},
|
||||
[
|
||||
profile,
|
||||
quota,
|
||||
aiRequestPriceInCredits,
|
||||
project,
|
||||
onOpenCreateAccountDialog,
|
||||
availableCredits,
|
||||
openCreditsPackageDialog,
|
||||
selectedAiRequestId,
|
||||
getAuthorizationHeader,
|
||||
onRefreshLimits,
|
||||
]
|
||||
);
|
||||
|
||||
const onSendFeedback = React.useCallback(
|
||||
async (aiRequestId, messageIndex, feedback, reason) => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
await retryIfFailed({ times: 2 }, () =>
|
||||
sendAiRequestFeedback(getAuthorizationHeader, {
|
||||
userId: profile.id,
|
||||
aiRequestId,
|
||||
messageIndex,
|
||||
feedback,
|
||||
reason,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error sending feedback: ', error);
|
||||
}
|
||||
},
|
||||
[getAuthorizationHeader, profile]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper square background="dark" style={styles.paper}>
|
||||
<div style={styles.chatContainer}>
|
||||
<AiRequestChat
|
||||
ref={aiRequestChatRef}
|
||||
aiRequest={selectedAiRequest}
|
||||
onSendUserRequest={sendUserRequest}
|
||||
isLaunchingAiRequest={isLaunchingAiRequest}
|
||||
lastSendError={lastError}
|
||||
quota={quota}
|
||||
increaseQuotaOffering={
|
||||
!hasValidSubscriptionPlan(subscription)
|
||||
? 'subscribe'
|
||||
: canUpgradeSubscription(subscription)
|
||||
? 'upgrade'
|
||||
: 'none'
|
||||
}
|
||||
aiRequestPriceInCredits={aiRequestPriceInCredits}
|
||||
availableCredits={availableCredits}
|
||||
onSendFeedback={onSendFeedback}
|
||||
hasOpenedProject={!!project}
|
||||
/>
|
||||
</div>
|
||||
</Paper>
|
||||
<AskAiHistory
|
||||
open={isHistoryOpen}
|
||||
onClose={onCloseHistory}
|
||||
onSelectAiRequest={setSelectedAiRequest}
|
||||
selectedAiRequestId={selectedAiRequestId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
),
|
||||
// Prevent any update to the editor if the editor is not active,
|
||||
// and so not visible to the user.
|
||||
(prevProps, nextProps) => prevProps.isActive || nextProps.isActive
|
||||
);
|
||||
|
||||
export const renderAskAiContainer = (
|
||||
props: RenderEditorContainerPropsWithRef
|
||||
) => (
|
||||
<AskAi
|
||||
ref={props.ref}
|
||||
project={props.project}
|
||||
setToolbar={props.setToolbar}
|
||||
isActive={props.isActive}
|
||||
/>
|
||||
);
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user