Compare commits

...

63 Commits

Author SHA1 Message Date
Florian Rival
3e4826bafc Fix memory leak with outlining in-game edited object 2025-01-15 09:50:09 +01:00
AlexandreSi
d914f9f5be Add comment 2025-01-07 15:51:40 +01:00
AlexandreSi
ab8c90dd41 Send layout name to runtime game when editing instance in editor 2025-01-07 15:32:52 +01:00
AlexandreSi
8882841a8d Fix position offset 2025-01-07 15:19:24 +01:00
AlexandreSi
3d357950f9 Send message to game when updating an instance position from the editor 2025-01-07 14:51:31 +01:00
AlexandreSi
ef5d2651c0 Update instance when receiving update from game and hot reload game 2025-01-03 17:15:32 +01:00
AlexandreSi
1dd4bb9b7a Add method to get instance persistent uuid 2025-01-03 16:39:06 +01:00
AlexandreSi
682e6f6b03 Send instances updated back to editor when modifying them with transform controls 2025-01-03 15:51:55 +01:00
AlexandreSi
b468c28ae8 Improve usability of the in-game instance control 2025-01-02 14:57:31 +01:00
AlexandreSi
9fc9452a08 Better handle gizmos 2025-01-02 12:29:53 +01:00
AlexandreSi
3b176b7152 Fix cleaning of listeners and helpers 2025-01-02 11:46:56 +01:00
AlexandreSi
f1d1a9b66b Remove unused import 2025-01-02 11:27:41 +01:00
AlexandreSi
c0d2f491a8 Fix code after rebase 2025-01-02 11:27:41 +01:00
AlexandreSi
aeecce2ea8 Avoid creating transform controls on each click 2025-01-02 11:05:05 +01:00
AlexandreSi
647ee149ff Fix hovered object outline 2025-01-02 11:05:05 +01:00
AlexandreSi
22313e148a Import changes to render without step 2025-01-02 11:05:05 +01:00
AlexandreSi
31f2d7ce2e Select object on click 2025-01-02 11:03:59 +01:00
AlexandreSi
4a0efec6c2 Add TransformControls to Three addons 2025-01-02 11:03:59 +01:00
AlexandreSi
5f555df5c1 Add outline pass in layer composer 2025-01-02 11:03:59 +01:00
AlexandreSi
53a611a1b9 Add outline pass to three addons 2025-01-02 11:03:59 +01:00
AlexandreSi
14511b23af WIP 2025-01-02 11:03:59 +01:00
Florian Rival
fa2371274d Add support for external layouts and reload when a change is made in the runtime 2025-01-02 00:10:45 +01:00
Florian Rival
0aea8dfa0f WIP: refactoring and prepare for handling external layout in-game edition 2024-12-31 17:07:21 +01:00
Florian Rival
81ca18098d Fix text object pre-rendering update 2024-12-31 16:04:11 +01:00
Florian Rival
b6e44a022f Add a note 2024-12-30 20:06:38 +01:00
Florian Rival
1a8eee2477 Remove game.resumed/paused in favor of status 2024-12-30 20:00:28 +01:00
Florian Rival
d0ef92da03 Fix flow and formatting 2024-12-30 19:25:43 +01:00
Florian Rival
9c98cb3b3b Relaunch an (in-game edition) preview when a change is made in GDJS Runtime 2024-12-30 18:56:09 +01:00
Florian Rival
3681542056 Robustify in game preview state management in the editor 2024-12-30 15:38:21 +01:00
Florian Rival
7c0bf135d7 WIP: Support for rendering while paused for in game edition 2024-12-30 12:32:05 +01:00
Florian Rival
9a31dd046c WIP: robustify scene change and initial scene for in game edition 2024-12-29 17:23:20 +01:00
Florian Rival
74401a1f9c WIP: Add scene change 2024-12-29 15:50:50 +01:00
Florian Rival
cedc6ea3e9 WIP 2024-12-29 15:50:50 +01:00
Florian Rival
5261f5a431 Remove logs for audio events when manipulating a sound/music on an unused channel (#7260) 2024-12-29 15:50:05 +01:00
AlexandreS
1a6cf8d69a Paginate feedbacks (#7259)
Also:
* Display 3 last projects only in context menu
2024-12-27 14:09:45 +01:00
AlexandreS
e93b38fee4 Fix asset preview being too zoomed in (#7255) 2024-12-27 14:08:06 +01:00
Florian Rival
22c7215071 Increase bottom drawer bottom margin on mobile
Don't show in changelog
2024-12-22 13:06:01 +01:00
Florian Rival
a221990c57 Ensure Home tab still shown when project opened on small screens
* Also smoothly scroll to the active tab
2024-12-22 12:43:33 +01:00
AlexandreS
85f6e74a5c Fix mobile horizontal scroll (#7254)
Don't show in changelog
2024-12-20 10:57:46 +01:00
Florian Rival
6577432b27 Bump newIDE version 2024-12-19 16:59:15 +01:00
AlexandreS
16d94b5e38 When duplicating a project, ask for the new name and if link with game should be kept (#7253)
Don't show in changelog
2024-12-19 16:50:38 +01:00
Florian Rival
88a2060364 Fix warning 2024-12-19 16:12:59 +01:00
Florian Rival
2d0ffee102 Improve subscription dialog and profile 2024-12-19 15:39:35 +01:00
D8H
aa30f3c465 Fix a regression on parameter editor lock (#7249)
Do not show in changelog
2024-12-19 11:43:41 +01:00
github-actions[bot]
cfcb4b557f Update translations [skip ci] (#7248)
Co-authored-by: ClementPasteau <4895034+ClementPasteau@users.noreply.github.com>
2024-12-19 11:42:58 +01:00
Clément Pasteau
d8db679a1d Show "Get Premium" button with enhanced colors (#7251) 2024-12-19 11:33:03 +01:00
Florian Rival
01503d46c1 Bump newIDE version 2024-12-18 18:16:59 +01:00
Florian Rival
02d44bbba4 Remove unused API method to initialize game rendering [skip ci] (#7235) 2024-12-18 18:16:13 +01:00
D8H
b6d8170a00 [3D character] Allow to set higher speed than the maximum speed (#7245)
Don't show in changelog
2024-12-18 18:15:50 +01:00
github-actions[bot]
554c4c8f58 Update translations [skip ci] (#7239)
Co-authored-by: 4ian <1280130+4ian@users.noreply.github.com>
2024-12-18 18:14:59 +01:00
D8H
9e29146841 Fix a regression on custom object life-cycle parameters not being locked (#7247) 2024-12-18 18:03:41 +01:00
AlexandreS
189e971cd2 Fix: Highlight the currently opened project if multiple projects with same uuid (#7244)
Don't show in changelog
2024-12-18 17:54:46 +01:00
Florian Rival
deab962081 Fix regression in extensions loading (#7242) 2024-12-18 12:26:56 +01:00
D8H
e2281dfd82 Add 3D physics and 3D character behaviors (#7149)
* The 3D physics engine is powered by [Jolt Physics](https://github.com/jrouwe/JoltPhysics) a modern, performant, battle-tested, fully featured 3D physics engine used in AAA games, like Horizon: Forbidden West. The new 3D physics engine is perfect for making FPS, TPS, 3D platformer and in the future 3D racing games or any 3D game.
* Similar to the 2D physics engine, you can add the 3D Physics behavior to your object. For example, add the behavior to platforms with the type set to "Static".
* You can choose the collider shape: box, sphere, cylinder or capsule and modify physics properties of the objects having this behavior - much like the 2D physics engine.
* For characters, a dedicated "3D Physics Character" behavior is available. Pair it with one or more additional behaviors to get controls working out of the box: "3D Shooter keyboard mapper", "3D Platformer keyboard mapper", etc...
* Take a look at the new examples to give it a try! New examples and behaviors will be progressively added.
2024-12-17 18:00:48 +01:00
AlexandreS
44daf709e4 Fix asset store display speed and reduce the burst of requests (#7241) 2024-12-17 17:37:02 +01:00
Florian Rival
c9e5272367 Allow to browse marketing boosts from analytics screen
Also fix a warning

Don't show in changelog
2024-12-17 16:38:11 +01:00
AlexandreS
63584d171f Change scene editor title depending if instance or object edited on mobile (#7240) 2024-12-17 14:07:51 +01:00
D8H
092efbe462 Merge the function properties and parameters tabs in the extension editor (#7231) 2024-12-17 13:18:38 +01:00
AlexandreS
fc86b4e2dd Fix: Apply z offset to instance even if no z in instance data (#7238) 2024-12-17 08:46:06 +01:00
github-actions[bot]
3415626552 Update translations [skip ci] (#7208)
Co-authored-by: AlexandreSi <32449369+AlexandreSi@users.noreply.github.com>
2024-12-16 16:36:27 +01:00
AlexandreS
fd2e87cc5c Polishing the new Create tab (#7236)
Don't show in changelog
2024-12-16 15:46:32 +01:00
Clément Pasteau
ecc8c3176d Remove unnecessary templates page (#7234)
Do not show in changelog
2024-12-16 12:13:12 +01:00
Clément Pasteau
1c855226a8 Fix loader on games list (#7233)
Don't show in changelog
2024-12-16 11:22:59 +01:00
202 changed files with 19482 additions and 1458 deletions

1
.gitattributes vendored
View File

@@ -3,6 +3,7 @@ Extensions/ParticleSystem/SPARK/* linguist-vendored
Extensions/PhysicsBehavior/Box2D/* linguist-vendored
Extensions/PhysicsBehavior/box2djs/* linguist-vendored
Extensions/Physics2Behavior/box2d.js linguist-vendored
Extensions/Physics3DBehavior/*.wasm-compat.js linguist-vendored
Extensions/BBText/pixi-multistyle-text/* linguist-vendored
Extensions/P2P/A_peer.js linguist-vendored
Extensions/Multiplayer/peer.js linguist-vendored

View File

@@ -365,6 +365,12 @@ class GD_CORE_API InitialInstance {
* the same initial instance between serialization.
*/
InitialInstance& ResetPersistentUuid();
/**
* \brief Reset the persistent UUID used to recognize
* the same initial instance between serialization.
*/
const gd::String& GetPersistentUuid() { return persistentUuid; };
///@}
private:

View File

@@ -936,6 +936,9 @@ void Project::UnserializeAndInsertExtensionsFrom(
const gd::SerializerElement &eventsFunctionsExtensionsElement) {
eventsFunctionsExtensionsElement.ConsiderAsArrayOf(
"eventsFunctionsExtension");
std::map<gd::String, size_t> extensionNameToElementIndex;
// First, only unserialize behaviors and objects names.
// As event based objects can contains custom behaviors and custom objects,
// this allows them to reference EventBasedBehavior and EventBasedObject
@@ -946,6 +949,7 @@ void Project::UnserializeAndInsertExtensionsFrom(
const SerializerElement& eventsFunctionsExtensionElement =
eventsFunctionsExtensionsElement.GetChild(i);
const gd::String& name = eventsFunctionsExtensionElement.GetStringAttribute("name");
extensionNameToElementIndex[name] = i;
gd::EventsFunctionsExtension& eventsFunctionsExtension =
HasEventsFunctionsExtensionNamed(name)
@@ -959,11 +963,25 @@ void Project::UnserializeAndInsertExtensionsFrom(
// Then unserialize functions, behaviors and objects content.
for (gd::String &extensionName :
GetUnserializingOrderExtensionNames(eventsFunctionsExtensionsElement)) {
size_t extensionIndex = GetEventsFunctionsExtensionPosition(extensionName);
const SerializerElement &eventsFunctionsExtensionElement =
eventsFunctionsExtensionsElement.GetChild(extensionIndex);
eventsFunctionsExtensions.at(extensionIndex)
size_t extensionIndex = GetEventsFunctionsExtensionPosition(extensionName);
if (extensionIndex == gd::String::npos) {
// Should never happen because the extension was added in the first pass.
gd::LogError("Can't find extension " + extensionName + " in the list of extensions in second pass of unserialization.");
continue;
}
auto& partiallyLoadedExtension = eventsFunctionsExtensions.at(extensionIndex);
if (extensionNameToElementIndex.find(extensionName) == extensionNameToElementIndex.end()) {
// Should never happen because the extension element is present.
gd::LogError("Can't find extension element to unserialize for " + extensionName + " in second pass of unserialization.");
continue;
}
size_t elementIndex = extensionNameToElementIndex[extensionName];
const SerializerElement &eventsFunctionsExtensionElement =
eventsFunctionsExtensionsElement.GetChild(elementIndex);
partiallyLoadedExtension
->UnserializeExtensionImplementationFrom(
*this, eventsFunctionsExtensionElement);
}
@@ -971,16 +989,24 @@ void Project::UnserializeAndInsertExtensionsFrom(
std::vector<gd::String> Project::GetUnserializingOrderExtensionNames(
const gd::SerializerElement &eventsFunctionsExtensionsElement) {
eventsFunctionsExtensionsElement.ConsiderAsArrayOf(
"eventsFunctionsExtension");
// Some extension have custom objects, which have child objects coming from other extension.
// These child objects must be loaded completely before the parent custom obejct can be unserialized.
// This implies: an order on the extension unserialization (and no cycles).
// At the beginning, everything is yet to be loaded.
std::map<gd::String, size_t> extensionNameToElementIndex;
std::vector<gd::String> remainingExtensionNames(
eventsFunctionsExtensions.size());
for (std::size_t i = 0; i < eventsFunctionsExtensions.size(); ++i) {
remainingExtensionNames[i] = eventsFunctionsExtensions.at(i)->GetName();
eventsFunctionsExtensionsElement.GetChildrenCount());
for (std::size_t i = 0; i < eventsFunctionsExtensionsElement.GetChildrenCount(); ++i) {
const SerializerElement& eventsFunctionsExtensionElement =
eventsFunctionsExtensionsElement.GetChild(i);
const gd::String& name = eventsFunctionsExtensionElement.GetStringAttribute("name");
remainingExtensionNames[i] = name;
extensionNameToElementIndex[name] = i;
}
// Helper allowing to find if an extension has an object that depends on
@@ -1029,10 +1055,10 @@ std::vector<gd::String> Project::GetUnserializingOrderExtensionNames(
foundAnyExtension = false;
for (std::size_t i = 0; i < remainingExtensionNames.size(); ++i) {
auto extensionName = remainingExtensionNames[i];
size_t extensionIndex =
GetEventsFunctionsExtensionPosition(extensionName);
size_t elementIndex = extensionNameToElementIndex[extensionName];
const SerializerElement &eventsFunctionsExtensionElement =
eventsFunctionsExtensionsElement.GetChild(extensionIndex);
eventsFunctionsExtensionsElement.GetChild(elementIndex);
if (!isDependentFromRemainingExtensions(
eventsFunctionsExtensionElement)) {

View File

@@ -1073,23 +1073,25 @@ class GD_CORE_API Project {
return wholeProjectDiagnosticReport;
}
private:
/**
* Initialize from another game. Used by copy-ctor and assign-op.
* Don't forget to update me if members were changed!
*/
void Init(const gd::Project& project);
/**
* @brief Get the project extensions names in the order they have to be
* unserialized.
*
* Child-objects need the event-based objects they use to be loaded completely
* before they are unserialized.
*
* \warning This is only public to allow testing - don't use it in the editor.
*/
std::vector<gd::String> GetUnserializingOrderExtensionNames(
static std::vector<gd::String> GetUnserializingOrderExtensionNames(
const gd::SerializerElement& eventsFunctionsExtensionsElement);
private:
/**
* Initialize from another game. Used by copy-ctor and assign-op.
* Don't forget to update me if members were changed!
*/
void Init(const gd::Project& project);
/**
* Create an object configuration of the given type.
*

View File

@@ -51,7 +51,9 @@ void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) {
currentValue = element.GetChild("value").GetStringValue();
type = element.GetChild("type").GetStringValue();
if (type == "Number") {
gd::String unitName = element.GetChild("unit").GetStringValue();
gd::String unitName = element.HasChild("unit")
? element.GetChild("unit").GetStringValue()
: "";
measurementUnit =
gd::MeasurementUnit::HasDefaultMeasurementUnitNamed(unitName)
? measurementUnit =

View File

@@ -0,0 +1,312 @@
/*
* GDevelop Core
* Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
/**
* @file Tests covering common features of GDevelop Core.
*/
#include <algorithm>
#include "DummyPlatform.h"
#include "GDCore/Extensions/Metadata/MetadataProvider.h"
#include "GDCore/Extensions/Platform.h"
#include "GDCore/Extensions/PlatformExtension.h"
#include "GDCore/Project/Object.h"
#include "GDCore/Project/Project.h"
#include "GDCore/String.h"
#include "catch.hpp"
TEST_CASE("Project::GetUnserializingOrderExtensionNames", "[common]") {
SECTION("Unserialization order is correct when nothing to load") {
gd::SerializerElement emptyElement;
std::vector<gd::String> orderedNames =
gd::Project::GetUnserializingOrderExtensionNames(emptyElement);
REQUIRE(orderedNames.size() == 0);
}
SECTION("One extension with no dependencies") {
gd::SerializerElement extensionsElement = gd::Serializer::FromJSON(
R"([
{
"author": "",
"category": "Input",
"extensionNamespace": "",
"fullName": "3D character keyboard mapper",
"helpPath": "",
"iconUrl": "fake-icon-url",
"name": "Extension1",
"previewIconUrl": "fake-preview-icon-url",
"shortDescription": "3D platformer and 3D shooter keyboard controls.",
"version": "1.0.0",
"description": "3D platformer and 3D shooter keyboard controls.",
"tags": [],
"authorIds": [],
"dependencies": [],
"globalVariables": [],
"sceneVariables": [],
"eventsFunctions": [],
"eventsBasedBehaviors": [],
"eventsBasedObjects": []
}
])");
std::vector<gd::String> orderedNames =
gd::Project::GetUnserializingOrderExtensionNames(extensionsElement);
REQUIRE(orderedNames.size() == 1);
REQUIRE(orderedNames[0] == "Extension1");
}
SECTION("One extension with a dependency outside the loaded extensions") {
gd::SerializerElement extensionsElement = gd::Serializer::FromJSON(
R"([
{
"author": "",
"category": "Input",
"extensionNamespace": "",
"fullName": "3D character keyboard mapper",
"helpPath": "",
"iconUrl": "fake-icon-url",
"name": "Extension1DependsOtherExtension",
"previewIconUrl": "fake-preview-icon-url",
"shortDescription": "3D platformer and 3D shooter keyboard controls.",
"version": "1.0.0",
"description": "3D platformer and 3D shooter keyboard controls.",
"tags": [],
"authorIds": [],
"dependencies": [],
"globalVariables": [],
"sceneVariables": [],
"eventsFunctions": [],
"eventsBasedBehaviors": [],
"eventsBasedObjects": [
{
"areaMaxX": 64,
"areaMaxY": 64,
"areaMaxZ": 64,
"areaMinX": 0,
"areaMinY": 0,
"areaMinZ": 0,
"defaultName": "Joystick",
"description": "Joystick for touchscreens.",
"fullName": "Multitouch Joystick",
"name": "SpriteMultitouchJoystick",
"eventsFunctions": [],
"propertyDescriptors": [],
"objects": [
{
"name": "Thumb",
"type": "OtherExtension::Whatever"
}
],
"objectsFolderStructure": {
"folderName": "__ROOT",
"children": []
},
"objectsGroups": [],
"layers": [],
"instances": []
}
]
}
])");
std::vector<gd::String> orderedNames =
gd::Project::GetUnserializingOrderExtensionNames(extensionsElement);
REQUIRE(orderedNames.size() == 1);
REQUIRE(orderedNames[0] == "Extension1DependsOtherExtension");
}
SECTION("4 extensions with dependencies on each others") {
gd::SerializerElement extensionsElement = gd::Serializer::FromJSON(
R"([
{
"author": "",
"category": "Input",
"extensionNamespace": "",
"fullName": "3D character keyboard mapper",
"helpPath": "",
"iconUrl": "fake-icon-url",
"name": "Extension4DependsOn1And3",
"previewIconUrl": "fake-preview-icon-url",
"shortDescription": "3D platformer and 3D shooter keyboard controls.",
"version": "1.0.0",
"description": "3D platformer and 3D shooter keyboard controls.",
"tags": [],
"authorIds": [],
"dependencies": [],
"globalVariables": [],
"sceneVariables": [],
"eventsFunctions": [],
"eventsBasedBehaviors": [],
"eventsBasedObjects": [
{
"areaMaxX": 64,
"areaMaxY": 64,
"areaMaxZ": 64,
"areaMinX": 0,
"areaMinY": 0,
"areaMinZ": 0,
"defaultName": "Joystick",
"description": "Joystick for touchscreens.",
"fullName": "Multitouch Joystick",
"name": "SpriteMultitouchJoystick",
"eventsFunctions": [],
"propertyDescriptors": [],
"objects": [
{
"name": "Thumb",
"type": "OtherExtension::Whatever"
},
{
"name": "Thumb2",
"type": "Extension1DependsNothing::Whatever"
}
],
"objectsFolderStructure": {
"folderName": "__ROOT",
"children": []
},
"objectsGroups": [],
"layers": [],
"instances": []
},
{
"areaMaxX": 64,
"areaMaxY": 64,
"areaMaxZ": 64,
"areaMinX": 0,
"areaMinY": 0,
"areaMinZ": 0,
"defaultName": "Joystick",
"description": "Joystick for touchscreens.",
"fullName": "Multitouch Joystick",
"name": "SpriteMultitouchJoystick",
"eventsFunctions": [],
"propertyDescriptors": [],
"objects": [
{
"name": "Thumb",
"type": "OtherExtension::Whatever"
},
{
"name": "Thumb2",
"type": "Extension3DependingOn2::Whatever"
}
],
"objectsFolderStructure": {
"folderName": "__ROOT",
"children": []
},
"objectsGroups": [],
"layers": [],
"instances": []
}
]
},
{
"author": "",
"category": "Input",
"extensionNamespace": "",
"fullName": "3D character keyboard mapper",
"helpPath": "",
"iconUrl": "fake-icon-url",
"name": "Extension3DependingOn2",
"previewIconUrl": "fake-preview-icon-url",
"shortDescription": "3D platformer and 3D shooter keyboard controls.",
"version": "1.0.0",
"description": "3D platformer and 3D shooter keyboard controls.",
"tags": [],
"authorIds": [],
"dependencies": [],
"globalVariables": [],
"sceneVariables": [],
"eventsFunctions": [],
"eventsBasedBehaviors": [],
"eventsBasedObjects": [
{
"areaMaxX": 64,
"areaMaxY": 64,
"areaMaxZ": 64,
"areaMinX": 0,
"areaMinY": 0,
"areaMinZ": 0,
"defaultName": "Joystick",
"description": "Joystick for touchscreens.",
"fullName": "Multitouch Joystick",
"name": "SpriteMultitouchJoystick",
"eventsFunctions": [],
"propertyDescriptors": [],
"objects": [
{
"name": "Thumb",
"type": "OtherExtension::Whatever"
},
{
"name": "Thumb2",
"type": "Extension2DependsNothing::Whatever"
}
],
"objectsFolderStructure": {
"folderName": "__ROOT",
"children": []
},
"objectsGroups": [],
"layers": [],
"instances": []
}
]
},
{
"author": "",
"category": "Input",
"extensionNamespace": "",
"fullName": "3D character keyboard mapper",
"helpPath": "",
"iconUrl": "fake-icon-url",
"name": "Extension2DependsNothing",
"previewIconUrl": "fake-preview-icon-url",
"shortDescription": "3D platformer and 3D shooter keyboard controls.",
"version": "1.0.0",
"description": "3D platformer and 3D shooter keyboard controls.",
"tags": [],
"authorIds": [],
"dependencies": [],
"globalVariables": [],
"sceneVariables": [],
"eventsFunctions": [],
"eventsBasedBehaviors": [],
"eventsBasedObjects": []
},
{
"author": "",
"category": "Input",
"extensionNamespace": "",
"fullName": "3D character keyboard mapper",
"helpPath": "",
"iconUrl": "fake-icon-url",
"name": "Extension1DependsNothing",
"previewIconUrl": "fake-preview-icon-url",
"shortDescription": "3D platformer and 3D shooter keyboard controls.",
"version": "1.0.0",
"description": "3D platformer and 3D shooter keyboard controls.",
"tags": [],
"authorIds": [],
"dependencies": [],
"globalVariables": [],
"sceneVariables": [],
"eventsFunctions": [],
"eventsBasedBehaviors": [],
"eventsBasedObjects": []
}
])");
std::vector<gd::String> orderedNames =
gd::Project::GetUnserializingOrderExtensionNames(extensionsElement);
REQUIRE(orderedNames.size() == 4);
REQUIRE(orderedNames[0] == "Extension2DependsNothing");
REQUIRE(orderedNames[1] == "Extension1DependsNothing");
REQUIRE(orderedNames[2] == "Extension3DependingOn2");
REQUIRE(orderedNames[3] == "Extension4DependsOn1And3");
}
}

View File

@@ -30,6 +30,14 @@ namespace gdjs {
);
}
getObjectPositionFrom3DRendererObject() {
return {
x: this._threeObject3D.position.x - this._object.getWidth() / 2,
y: this._threeObject3D.position.y - this._object.getHeight() / 2,
z: this._threeObject3D.position.z - this._object.getDepth() / 2,
};
}
updateRotation() {
this._threeObject3D.rotation.set(
gdjs.toRad(this._object.getRotationX()),

View File

@@ -695,7 +695,7 @@ module.exports = {
.addCondition(
'IsDynamic',
_('Is dynamic'),
_('Test if an object is dynamic.'),
_('Check if an object is dynamic.'),
_('_PARAM0_ is dynamic'),
_('Dynamics'),
'res/physics32.png',
@@ -727,7 +727,7 @@ module.exports = {
.addCondition(
'IsStatic',
_('Is static'),
_('Test if an object is static.'),
_('Check if an object is static.'),
_('_PARAM0_ is static'),
_('Dynamics'),
'res/physics32.png',
@@ -759,7 +759,7 @@ module.exports = {
.addCondition(
'IsKinematic',
_('Is kinematic'),
_('Test if an object is kinematic.'),
_('Check if an object is kinematic.'),
_('_PARAM0_ is kinematic'),
_('Dynamics'),
'res/physics32.png',
@@ -790,9 +790,9 @@ module.exports = {
aut
.addCondition(
'IsBullet',
_('Is treat as bullet'),
_('Test if an object is being treat as a bullet.'),
_('_PARAM0_ is bullet'),
_('Is treated as a bullet'),
_('Check if the object is being treated as a bullet.'),
_('_PARAM0_ is treated as a bullet'),
_('Dynamics'),
'res/physics32.png',
'res/physics32.png'
@@ -825,7 +825,7 @@ module.exports = {
.addCondition(
'HasFixedRotation',
_('Has fixed rotation'),
_('Test if an object has fixed rotation.'),
_('Check if an object has fixed rotation.'),
_('_PARAM0_ has fixed rotation'),
_('Dynamics'),
'res/physics32.png',
@@ -859,7 +859,7 @@ module.exports = {
.addCondition(
'IsSleepingAllowed',
_('Is sleeping allowed'),
_('Test if an object can sleep.'),
_('Check if an object can sleep.'),
_('_PARAM0_ can sleep'),
_('Dynamics'),
'res/physics32.png',
@@ -898,7 +898,7 @@ module.exports = {
.addCondition(
'IsSleeping',
_('Is sleeping'),
_('Test if an object is sleeping.'),
_('Check if an object is sleeping.'),
_('_PARAM0_ is sleeping'),
_('Dynamics'),
'res/physics32.png',
@@ -1267,7 +1267,7 @@ module.exports = {
.addCondition(
'LayerEnabled',
_('Layer enabled'),
_('Test if an object has a specific layer enabled.'),
_('Check if an object has a specific layer enabled.'),
_('_PARAM0_ has layer _PARAM2_ enabled'),
_('Filtering'),
'res/physics32.png',
@@ -1303,7 +1303,7 @@ module.exports = {
.addCondition(
'MaskEnabled',
_('Mask enabled'),
_('Test if an object has a specific mask enabled.'),
_('Check if an object has a specific mask enabled.'),
_('_PARAM0_ has mask _PARAM2_ enabled'),
_('Filtering'),
'res/physics32.png',
@@ -1897,7 +1897,7 @@ module.exports = {
.addCondition(
'JointFirstObject',
_('Joint first object'),
_('Test if an object is the first object on a joint.'),
_('Check if an object is the first object on a joint.'),
_('_PARAM0_ is the first object for joint _PARAM2_'),
_('Joints'),
'res/physics32.png',
@@ -1913,7 +1913,7 @@ module.exports = {
.addCondition(
'JointSecondObject',
_('Joint second object'),
_('Test if an object is the second object on a joint.'),
_('Check if an object is the second object on a joint.'),
_('_PARAM0_ is the second object for joint _PARAM2_'),
_('Joints'),
'res/physics32.png',
@@ -2382,7 +2382,7 @@ module.exports = {
.addCondition(
'RevoluteJointLimitsEnabled',
_('Revolute joint limits enabled'),
_('Test if a revolute joint limits are enabled.'),
_('Check if a revolute joint limits are enabled.'),
_('Limits for revolute joint _PARAM2_ are enabled'),
_('Joints/Revolute'),
'JsPlatform/Extensions/revolute_joint24.png',
@@ -2461,7 +2461,7 @@ module.exports = {
.addCondition(
'RevoluteJointMotorEnabled',
_('Revolute joint motor enabled'),
_('Test if a revolute joint motor is enabled.'),
_('Check if a revolute joint motor is enabled.'),
_('Motor of revolute joint _PARAM2_ is enabled'),
_('Joints/Revolute'),
'JsPlatform/Extensions/revolute_joint24.png',
@@ -2700,7 +2700,7 @@ module.exports = {
.addCondition(
'PrismaticJointLimitsEnabled',
_('Prismatic joint limits enabled'),
_('Test if a prismatic joint limits are enabled.'),
_('Check if a prismatic joint limits are enabled.'),
_('Limits for prismatic joint _PARAM2_ are enabled'),
_('Joints/Prismatic'),
'JsPlatform/Extensions/prismatic_joint24.png',
@@ -2779,7 +2779,7 @@ module.exports = {
.addCondition(
'PrismaticJointMotorEnabled',
_('Prismatic joint motor enabled'),
_('Test if a prismatic joint motor is enabled.'),
_('Check if a prismatic joint motor is enabled.'),
_('Motor for prismatic joint _PARAM2_ is enabled'),
_('Joints/Prismatic'),
'JsPlatform/Extensions/prismatic_joint24.png',
@@ -3459,7 +3459,7 @@ module.exports = {
.addCondition(
'WheelJointMotorEnabled',
_('Wheel joint motor enabled'),
_('Test if a wheel joint motor is enabled.'),
_('Check if a wheel joint motor is enabled.'),
_('Motor for wheel joint _PARAM2_ is enabled'),
_('Joints/Wheel'),
'JsPlatform/Extensions/wheel_joint24.png',
@@ -4203,7 +4203,7 @@ module.exports = {
.getCodeExtraInformation()
.addIncludeFile('Extensions/Physics2Behavior/physics2tools.js')
.addIncludeFile('Extensions/Physics2Behavior/physics2runtimebehavior.js')
.setFunctionName('gdjs.physics2.objectsCollide');
.setFunctionName('gdjs.physics2.areObjectsColliding');
extension
.addCondition(

View File

@@ -960,10 +960,10 @@ namespace gdjs {
updateObjectFromBody() {
// Copy transform from body to the GD object.
// It's possible the behavior was either deactivated or the object deleted
// just before this doStepPreEvents (for example, another behavior deleted
// the object during its own doStepPreEvents). If the body is null, we just
// don't do anything (but still run the physics simulation - this is independent).
// The body is null when the behavior was either deactivated or the object deleted.
// It would be useless to try to recreate it as updateBodyFromObject already does it.
// If the body is null, we just don't do anything
// (but still run the physics simulation - this is independent).
if (this._body !== null) {
this.owner.setX(
this._body.GetPosition().get_x() * this._sharedData.worldScale -

View File

@@ -1,6 +1,6 @@
namespace gdjs {
export namespace physics2 {
export const objectsCollide = function (
export const areObjectsColliding = function (
objectsLists1: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName: string,
objectsLists2: Hashtable<Array<gdjs.RuntimeObject>>,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
namespace gdjs {
export namespace physics3d {
export const areObjectsColliding = function (
objectsLists1: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName: string,
objectsLists2: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName2: string,
inverted: boolean
) {
return gdjs.evtTools.object.twoListsTest(
gdjs.Physics3DRuntimeBehavior.areObjectsColliding,
objectsLists1,
objectsLists2,
inverted,
behaviorName
);
};
export const haveObjectsStartedColliding = function (
objectsLists1: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName: string,
objectsLists2: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName2: string,
inverted: boolean
) {
return gdjs.evtTools.object.twoListsTest(
gdjs.Physics3DRuntimeBehavior.hasCollisionStartedBetween,
objectsLists1,
objectsLists2,
inverted,
behaviorName
);
};
export const haveObjectsStoppedColliding = function (
objectsLists1: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName: string,
objectsLists2: Hashtable<Array<gdjs.RuntimeObject>>,
behaviorName2: string,
inverted: boolean
) {
return gdjs.evtTools.object.twoListsTest(
gdjs.Physics3DRuntimeBehavior.hasCollisionStoppedBetween,
objectsLists1,
objectsLists2,
inverted,
behaviorName
);
};
type BehaviorNamePair = { character: string; physics: string };
const isOnPlatformAdapter = (
characterObject: gdjs.RuntimeObject,
physicsObject: gdjs.RuntimeObject,
behaviorNamePair: BehaviorNamePair
): boolean => {
const characterBehavior = characterObject.getBehavior(
behaviorNamePair.character
) as gdjs.PhysicsCharacter3DRuntimeBehavior;
const physicsBehavior = physicsObject.getBehavior(
behaviorNamePair.physics
) as gdjs.Physics3DRuntimeBehavior;
if (!characterBehavior || !physicsBehavior) {
return false;
}
return characterBehavior.isOnFloorObject(physicsBehavior);
};
const behaviorNamePair: BehaviorNamePair = { character: '', physics: '' };
export const isOnPlatform = (
characterObjectsLists: Hashtable<Array<gdjs.RuntimeObject>>,
characterBehaviorName: string,
physicsObjectsLists: Hashtable<Array<gdjs.RuntimeObject>>,
physicsBehaviorName: string,
inverted: boolean
) => {
behaviorNamePair.character = characterBehaviorName;
behaviorNamePair.physics = physicsBehaviorName;
return gdjs.evtTools.object.twoListsTest(
isOnPlatformAdapter,
characterObjectsLists,
physicsObjectsLists,
inverted,
behaviorNamePair
);
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
## Physics 3D Behaviors for GDevelop
This is the 3D physics engine for GDevelop, based on [Jolt Physics](https://github.com/jrouwe/JoltPhysics.js/) (WebAssembly, version 0.30.0).

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1 @@
describe('Physics3DRuntimeBehavior', () => {});

View File

@@ -231,9 +231,13 @@ namespace gdjs {
...super.getNetworkSyncData(),
props: {
cs: this._currentSpeed,
// TODO Try to remove these 3 fields from the synch
// They are reset every frame and are not part of the state.
rdx: this._requestedDeltaX,
rdy: this._requestedDeltaY,
ldy: this._lastDeltaY,
cfs: this._currentFallSpeed,
cj: this._canJump,
ldl: this._lastDirectionIsLeft,

View File

@@ -320,7 +320,7 @@ namespace gdjs {
return this._renderer.getRendererObject();
}
update(instanceContainer: gdjs.RuntimeInstanceContainer): void {
updatePreRender(instanceContainer: gdjs.RuntimeInstanceContainer): void {
this._renderer.ensureUpToDate();
}

View File

@@ -216,17 +216,30 @@ bool ExporterHelper::ExportProjectForPixiPreview(
// Strip the project (*after* generating events as the events may use stripped
// things (objects groups...))
gd::ProjectStripper::StripProjectForExport(exportedProject);
exportedProject.SetFirstLayout(options.layoutName);
previousTime = LogTimeSpent("Data stripping", previousTime);
// Create the setup options passed to the gdjs.RuntimeGame
gd::SerializerElement runtimeGameOptions;
runtimeGameOptions.AddChild("isPreview").SetBoolValue(true);
if (!options.externalLayoutName.empty()) {
runtimeGameOptions.AddChild("injectExternalLayout")
.SetValue(options.externalLayoutName);
auto &initialRuntimeGameStatus =
runtimeGameOptions.AddChild("initialRuntimeGameStatus");
initialRuntimeGameStatus.AddChild("sceneName")
.SetStringValue(options.layoutName);
if (options.isInGameEdition) {
initialRuntimeGameStatus.AddChild("isInGameEdition").SetBoolValue(true);
}
if (!options.externalLayoutName.empty()) {
initialRuntimeGameStatus.AddChild("injectedExternalLayoutName")
.SetValue(options.externalLayoutName);
if (options.isInGameEdition) {
initialRuntimeGameStatus.AddChild("skipCreatingInstancesFromScene")
.SetBoolValue(true);
}
}
runtimeGameOptions.AddChild("projectDataOnlyExport")
.SetBoolValue(options.projectDataOnlyExport);
runtimeGameOptions.AddChild("nativeMobileApp")

View File

@@ -45,6 +45,7 @@ struct PreviewExportOptions {
projectDataOnlyExport(false),
fullLoadingScreen(false),
isDevelopmentEnvironment(false),
isInGameEdition(false),
nonRuntimeScriptsCacheBurst(0),
fallbackAuthorId(""),
fallbackAuthorUsername(""),
@@ -169,6 +170,14 @@ struct PreviewExportOptions {
return *this;
}
/**
* \brief Set if the export is made for being edited in the editor.
*/
PreviewExportOptions &SetIsInGameEdition(bool enable) {
isInGameEdition = enable;
return *this;
}
/**
* \brief If set to a non zero value, the exported script URLs will have an
* extra search parameter added (with the given value) to ensure browser cache
@@ -291,6 +300,7 @@ struct PreviewExportOptions {
bool projectDataOnlyExport;
bool fullLoadingScreen;
bool isDevelopmentEnvironment;
bool isInGameEdition;
unsigned int nonRuntimeScriptsCacheBurst;
gd::String electronRemoteRequirePath;
gd::String gdevelopResourceToken;

View File

@@ -342,8 +342,7 @@ namespace gdjs {
newObject.setPosition(instanceData.x + xPos, instanceData.y + yPos);
newObject.setAngle(instanceData.angle);
if (gdjs.Base3DHandler && gdjs.Base3DHandler.is3D(newObject)) {
if (instanceData.z !== undefined)
newObject.setZ(instanceData.z + zOffset);
newObject.setZ((instanceData.z || 0) + zOffset);
if (instanceData.rotationX !== undefined)
newObject.setRotationX(instanceData.rotationX);
if (instanceData.rotationY !== undefined)

View File

@@ -245,6 +245,8 @@ namespace gdjs {
that.sendRuntimeGameDump();
} else if (data.command === 'refresh') {
that.sendRuntimeGameDump();
} else if (data.command === 'getStatus') {
that.sendRuntimeGameStatus();
} else if (data.command === 'set') {
that.set(data.path, data.newValue);
} else if (data.command === 'call') {
@@ -260,10 +262,89 @@ namespace gdjs {
that.sendProfilerStarted();
} else if (data.command === 'profiler.stop') {
runtimeGame.stopCurrentSceneProfiler();
} else if (data.command === 'instances.updated') {
runtimeGame._editor.reloadInstances(data.payload);
} else if (data.command === 'hotReload') {
that._hotReloader.hotReload().then((logs) => {
that.sendHotReloaderLogs(logs);
// TODO: if fatal error, should probably reload. The editor should handle this
// as it knows the current scene to show.
});
} else if (data.command === 'switchForInGameEdition') {
if (!this._runtimegame.isInGameEdition()) return;
const sceneName = data.sceneName || null;
const externalLayoutName = data.externalLayoutName || null;
if (!sceneName) {
logger.warn('No scene name specified, switchForInGameEdition aborted');
return;
}
const runtimeGameOptions = this._runtimegame.getAdditionalOptions();
if (runtimeGameOptions.initialRuntimeGameStatus) {
// Skip changing the scene if we're already on the state that is being requested.
if (
runtimeGameOptions.initialRuntimeGameStatus.sceneName ===
sceneName &&
runtimeGameOptions.initialRuntimeGameStatus
.injectedExternalLayoutName === externalLayoutName
) {
return;
}
}
runtimeGame
.getSceneStack()
.replace({
sceneName,
externalLayoutName,
skipCreatingInstancesFromScene: !!externalLayoutName,
clear: true,
});
// Update initialRuntimeGameStatus so that a hard reload
// will come back to the same state, and so that we can check later
// if the game is already on the state that is being requested.
runtimeGameOptions.initialRuntimeGameStatus = {
isPaused: runtimeGame.isPaused(),
isInGameEdition: runtimeGame.isInGameEdition(),
sceneName: sceneName,
injectedExternalLayoutName: externalLayoutName,
skipCreatingInstancesFromScene: !!externalLayoutName,
};
} else if (data.command === 'updateInstances') {
// TODO: do an update/partial hot reload of the instances
} else if (data.command === 'hardReload') {
// This usually means that the preview was modified so much that an entire reload
// is needed, or that the runtime itself could have been modified.
try {
const reloadUrl = new URL(location.href);
// Construct the initial status to be restored.
const initialRuntimeGameStatus = this._runtimegame.getAdditionalOptions()
.initialRuntimeGameStatus;
const runtimeGameStatus: RuntimeGameStatus = {
isPaused: this._runtimegame.isPaused(),
isInGameEdition: this._runtimegame.isInGameEdition(),
sceneName: initialRuntimeGameStatus?.sceneName || null,
injectedExternalLayoutName:
initialRuntimeGameStatus?.injectedExternalLayoutName || null,
skipCreatingInstancesFromScene:
initialRuntimeGameStatus?.skipCreatingInstancesFromScene || false,
};
reloadUrl.searchParams.set(
'runtimeGameStatus',
JSON.stringify(runtimeGameStatus)
);
location.replace(reloadUrl);
} catch (error) {
logger.error(
'Could not reload the game with the new initial status',
error
);
location.reload();
}
} else {
logger.info(
'Unknown command "' + data.command + '" received by the debugger.'
@@ -434,6 +515,20 @@ namespace gdjs {
return true;
}
sendRuntimeGameStatus(): void {
const currentScene = this._runtimegame.getSceneStack().getCurrentScene();
this._sendMessage(
circularSafeStringify({
command: 'status',
payload: {
isPaused: this._runtimegame.isPaused(),
isInGameEdition: this._runtimegame.isInGameEdition(),
sceneName: currentScene ? currentScene.getName() : null,
},
})
);
}
/**
* Dump all the relevant data from the {@link RuntimeGame} instance and send it to the server.
*/
@@ -543,26 +638,23 @@ namespace gdjs {
);
}
/**
* Callback called when the game is paused.
*/
sendGamePaused(): void {
sendInstancesUpdated(
objectUpdates: Array<{
object: RuntimeObject3D;
position: { x: number; y: number; z: number };
}>,
layoutName: string
): void {
this._sendMessage(
circularSafeStringify({
command: 'game.paused',
payload: null,
})
);
}
/**
* Callback called when the game is resumed.
*/
sendGameResumed(): void {
this._sendMessage(
circularSafeStringify({
command: 'game.resumed',
payload: null,
command: 'instances.updated',
payload: {
layoutName,
instances: objectUpdates.map((objectUpdate) => ({
persistentUuid: objectUpdate.object.persistentUuid,
position: objectUpdate.position,
})),
},
})
);
}

View File

@@ -144,8 +144,9 @@ namespace gdjs {
});
}
hotReload(): Promise<HotReloaderLog[]> {
async hotReload(): Promise<HotReloaderLog[]> {
logger.info('Hot reload started');
const wasPaused = this._runtimeGame.isPaused();
this._runtimeGame.pause(true);
this._logs = [];
@@ -168,62 +169,59 @@ namespace gdjs {
}
// Reload projectData and runtimeGameOptions stored by convention in data.js:
return this._reloadScript('data.js').then(() => {
const newProjectData: ProjectData = gdjs.projectData;
await this._reloadScript('data.js');
const newRuntimeGameOptions: RuntimeGameOptions =
gdjs.runtimeGameOptions;
const newProjectData: ProjectData = gdjs.projectData;
const newScriptFiles = newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[];
const projectDataOnlyExport = !!newRuntimeGameOptions.projectDataOnlyExport;
const newRuntimeGameOptions: RuntimeGameOptions = gdjs.runtimeGameOptions;
// Reload the changed scripts, which will have the side effects of re-running
// the new scripts, potentially replacing the code of the free functions from
// extensions (which is fine) and registering updated behaviors (which will
// need to be re-instantiated in runtime objects).
return this.reloadScriptFiles(
const newScriptFiles = newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[];
const projectDataOnlyExport = !!newRuntimeGameOptions.projectDataOnlyExport;
// Reload the changed scripts, which will have the side effects of re-running
// the new scripts, potentially replacing the code of the free functions from
// extensions (which is fine) and registering updated behaviors (which will
// need to be re-instantiated in runtime objects).
try {
await this.reloadScriptFiles(
newProjectData,
oldScriptFiles,
newScriptFiles,
projectDataOnlyExport
)
.then(() => {
const changedRuntimeBehaviors = this._computeChangedRuntimeBehaviors(
oldBehaviorConstructors,
gdjs.behaviorsTypes.items
);
return this._hotReloadRuntimeGame(
oldProjectData,
newProjectData,
changedRuntimeBehaviors,
this._runtimeGame
);
})
.catch((error) => {
const errorTarget = error.target;
if (errorTarget instanceof HTMLScriptElement) {
this._logs.push({
kind: 'fatal',
message: 'Unable to reload script: ' + errorTarget.src,
});
} else {
this._logs.push({
kind: 'fatal',
message:
'Unexpected error happened while hot-reloading: ' +
error.message,
});
}
})
.then(() => {
logger.info(
'Hot reload finished with logs:',
this._logs.map((log) => '\n' + log.kind + ': ' + log.message)
);
this._runtimeGame.pause(false);
return this._logs;
);
const changedRuntimeBehaviors = this._computeChangedRuntimeBehaviors(
oldBehaviorConstructors,
gdjs.behaviorsTypes.items
);
await this._hotReloadRuntimeGame(
oldProjectData,
newProjectData,
changedRuntimeBehaviors,
this._runtimeGame
);
} catch (error) {
const errorTarget = error.target;
if (errorTarget instanceof HTMLScriptElement) {
this._logs.push({
kind: 'fatal',
message: 'Unable to reload script: ' + errorTarget.src,
});
});
} else {
this._logs.push({
kind: 'fatal',
message:
'Unexpected error happened while hot-reloading: ' + error.message,
});
}
}
logger.info(
'Hot reload finished with logs:',
this._logs.map((log) => '\n' + log.kind + ': ' + log.message)
);
this._runtimeGame.pause(wasPaused);
return this._logs;
}
_computeChangedRuntimeBehaviors(

View File

@@ -6,8 +6,6 @@
namespace gdjs {
export namespace evtTools {
export namespace sound {
const logger = new gdjs.Logger('Audio events');
export const getGlobalVolume = function (
runtimeScene: gdjs.RuntimeScene
): float {
@@ -64,9 +62,6 @@ namespace gdjs {
.getSoundManager()
.getSoundOnChannel(channel);
if (sound) sound.stop();
else {
logger.error(`Cannot stop non-existing sound on channel ${channel}.`);
}
};
export const pauseSoundOnChannel = function (
@@ -78,11 +73,6 @@ namespace gdjs {
.getSoundManager()
.getSoundOnChannel(channel);
if (sound) sound.pause();
else {
logger.error(
`Cannot pause non-existing sound on channel ${channel}.`
);
}
};
export const continueSoundOnChannel = function (
@@ -95,10 +85,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) {
if (!sound.playing()) sound.play();
} else {
logger.error(
`Cannot continue playing non-existing sound on channel ${channel}.`
);
}
};
@@ -123,9 +109,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) return sound.paused();
else {
logger.error(
`Cannot check if non-existing sound on channel ${channel} is paused.`
);
return false;
}
};
@@ -140,9 +123,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) return sound.stopped();
else {
logger.error(
`Cannot check if non-existing sound on channel ${channel} is stopped.`
);
return true;
}
};
@@ -157,9 +137,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) return sound.getVolume() * 100;
else {
logger.error(
`Cannot get the volume of a non-existing sound on channel ${channel}.`
);
return 100;
}
};
@@ -174,11 +151,6 @@ namespace gdjs {
.getSoundManager()
.getSoundOnChannel(channel);
if (sound) sound.setVolume(volume / 100);
else {
logger.error(
`Cannot set the volume of a non-existing sound on channel ${channel}.`
);
}
};
export const getSoundOnChannelPlayingOffset = function (
@@ -191,9 +163,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) return sound.getSeek();
else {
logger.error(
`Cannot get the playing offset of a non-existing sound on channel ${channel}.`
);
return 0;
}
};
@@ -208,11 +177,6 @@ namespace gdjs {
.getSoundManager()
.getSoundOnChannel(channel);
if (sound) sound.setSeek(playingOffset);
else {
logger.error(
`Cannot set the playing offset of a non-existing sound on channel ${channel}.`
);
}
};
export const getSoundOnChannelPitch = function (
@@ -225,9 +189,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) return sound.getRate();
else {
logger.error(
`Cannot get the pitch of a non-existing sound on channel ${channel}.`
);
return 1;
}
};
@@ -242,11 +203,6 @@ namespace gdjs {
.getSoundManager()
.getSoundOnChannel(channel);
if (sound) sound.setRate(pitch);
else {
logger.error(
`Cannot get the pitch of a non-existing sound on channel ${channel}.`
);
}
};
export const preloadSound = (
@@ -304,11 +260,6 @@ namespace gdjs {
.getSoundManager()
.getMusicOnChannel(channel);
if (music) music.stop();
else {
logger.error(
`Cannot stop a non-existing music on channel ${channel}.`
);
}
};
export const pauseMusicOnChannel = function (
@@ -320,11 +271,6 @@ namespace gdjs {
.getSoundManager()
.getMusicOnChannel(channel);
if (music) music.pause();
else {
logger.error(
`Cannot pause a non-existing music on channel ${channel}.`
);
}
};
export const continueMusicOnChannel = function (
@@ -337,10 +283,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) {
if (!music.playing()) music.play();
} else {
logger.error(
`Cannot stop a non-existing music on channel ${channel}.`
);
}
};
@@ -365,9 +307,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) return music.paused();
else {
logger.error(
`Cannot check if non-existing music on channel ${channel} is paused.`
);
return false;
}
};
@@ -382,9 +321,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) return music.stopped();
else {
logger.error(
`Cannot check if non-existing music on channel ${channel} is stopped.`
);
return true;
}
};
@@ -399,9 +335,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) return music.getVolume() * 100;
else {
logger.error(
`Cannot get the volume of a non-existing music on channel ${channel}.`
);
return 100;
}
};
@@ -416,11 +349,6 @@ namespace gdjs {
.getSoundManager()
.getMusicOnChannel(channel);
if (music) music.setVolume(volume / 100);
else {
logger.error(
`Cannot set the volume of a non-existing music on channel ${channel}.`
);
}
};
export const getMusicOnChannelPlayingOffset = function (
@@ -433,9 +361,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) return music.getSeek();
else {
logger.error(
`Cannot get the playing offset of a non-existing music on channel ${channel}.`
);
return 0;
}
};
@@ -450,11 +375,6 @@ namespace gdjs {
.getSoundManager()
.getMusicOnChannel(channel);
if (music) music.setSeek(playingOffset);
else {
logger.error(
`Cannot set the playing offset of a non-existing music on channel ${channel}.`
);
}
};
export const getMusicOnChannelPitch = function (
@@ -467,9 +387,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) return music.getRate();
else {
logger.error(
`Cannot get the pitch of a non-existing music on channel ${channel}.`
);
return 1;
}
};
@@ -484,11 +401,6 @@ namespace gdjs {
.getSoundManager()
.getMusicOnChannel(channel);
if (music) music.setRate(pitch);
else {
logger.error(
`Cannot get the pitch of a non-existing music on channel ${channel}.`
);
}
};
export const preloadMusic = (
@@ -521,10 +433,6 @@ namespace gdjs {
.getSoundOnChannel(channel);
if (sound) {
sound.fade(sound.getVolume(), toVolume / 100, timeOfFade * 1000);
} else {
logger.error(
`Cannot fade the volume of a non-existing sound on channel ${channel}.`
);
}
};
export const fadeMusicVolume = (
@@ -539,10 +447,6 @@ namespace gdjs {
.getMusicOnChannel(channel);
if (music) {
music.fade(music.getVolume(), toVolume / 100, timeOfFade * 1000);
} else {
logger.error(
`Cannot fade the volume of a non-existing music on channel ${channel}.`
);
}
};
}

File diff suppressed because one or more lines are too long

View File

@@ -62,7 +62,7 @@ namespace gdjs {
/**
* Create the canvas on which the game will be rendered, inside the specified DOM element, and
* setup the rendering of the game.
* If you want to use your own canvas, use `initializeForCanvas` instead.
* If you want to use your own canvas, use `initializeRenderers` and `initializeCanvas` instead.
*
* @param parentElement The parent element to which the canvas will be added.
*/
@@ -72,20 +72,18 @@ namespace gdjs {
const gameCanvas = document.createElement('canvas');
parentElement.appendChild(gameCanvas);
this.initializeForCanvas(gameCanvas);
}
/**
* Setup the rendering of the game to use a canvas that was already created.
* @param gameCanvas The canvas to use.
*/
initializeForCanvas(gameCanvas: HTMLCanvasElement): void {
this._throwIfDisposed();
this.initializeRenderers(gameCanvas);
this.initializeCanvas(gameCanvas);
}
/**
* Set up the rendering of the game for the given canvas.
*
* In most cases, you can use `createStandardCanvas` instead to initialize the game.
*/
initializeRenderers(gameCanvas: HTMLCanvasElement): void {
this._throwIfDisposed();
if (typeof THREE !== 'undefined') {
this._threeRenderer = new THREE.WebGLRenderer({
canvas: gameCanvas,
@@ -136,6 +134,10 @@ namespace gdjs {
delete this._pixiRenderer.plugins.accessibility;
}
/**
* Set up the game canvas so that it covers the size required by the game
* and has a container for DOM elements required by the game.
*/
initializeCanvas(gameCanvas: HTMLCanvasElement): void {
// Add the renderer view element to the DOM
this._gameCanvas = gameCanvas;
@@ -537,6 +539,8 @@ namespace gdjs {
/**
* Add the standard events handler.
*
* The game canvas must have been initialized before calling this.
*/
bindStandardEvents(
manager: gdjs.InputManager,

View File

@@ -41,14 +41,54 @@ namespace gdjs {
return supportedCompressionMethods;
};
/**
* The desired status of the game, used for previews or in-game edition.
* Either stored in the options generated by the preview or in the URL
* in case of a hard reload.
*/
export type RuntimeGameStatus = {
isPaused: boolean;
isInGameEdition: boolean;
sceneName: string | null;
injectedExternalLayoutName: string | null;
skipCreatingInstancesFromScene: boolean;
};
/**
* Read the desired status of the game from the URL. Only useful for previews
* when hard reloaded.
*/
const readRuntimeGameStatusFromUrl = (): RuntimeGameStatus | null => {
try {
const url = new URL(location.href);
const runtimeGameStatus = url.searchParams.get('runtimeGameStatus');
if (!runtimeGameStatus) return null;
const parsedRuntimeGameStatus = JSON.parse(runtimeGameStatus);
return {
isPaused: !!parsedRuntimeGameStatus.isPaused,
isInGameEdition: !!parsedRuntimeGameStatus.isInGameEdition,
sceneName: '' + parsedRuntimeGameStatus.sceneName,
injectedExternalLayoutName:
'' + parsedRuntimeGameStatus.injectedExternalLayoutName,
skipCreatingInstancesFromScene: !!parsedRuntimeGameStatus.skipCreatingInstancesFromScene,
};
} catch (e) {
return null;
}
};
/** Options given to the game at startup. */
export type RuntimeGameOptions = {
/** if true, force fullscreen. */
forceFullscreen?: boolean;
/** if true, game is run as a preview launched from an editor. */
isPreview?: boolean;
/** The name of the external layout to create in the scene at position 0;0. */
injectExternalLayout?: string;
/** if set, the status of the game to be restored. */
initialRuntimeGameStatus?: RuntimeGameStatus;
/** Script files, used for hot-reloading. */
scriptFiles?: Array<RuntimeGameOptionsScriptFile>;
/** if true, export is a partial preview without events. */
@@ -118,6 +158,239 @@ namespace gdjs {
environment?: 'dev';
};
class RuntimeEditor {
_game: RuntimeGame;
_pointer = new THREE.Vector2();
// TODO: Use array
_selectedObjectData: {
intersect: THREE.Intersection;
camera: THREE.Camera;
scene: THREE.Scene;
} | null = null;
_outlinePasses: Record<string, THREE_ADDONS.OutlinePass> = {};
_raycaster = new THREE.Raycaster()
_editionAbortController: AbortController = new AbortController();
_clearCurrentPasses: (() => void)[] = [];
_currentTransformControls: THREE_ADDONS.TransformControls | null = null;
_shouldIgnoreNextClick: boolean = false;
constructor(game: RuntimeGame) {
this._game = game;
}
onPointerMove(event) {
// calculate pointer position in normalized device coordinates
// (-1 to +1) for both components
this._pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
this._pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
selectObject() {
if (this._shouldIgnoreNextClick) {
this._shouldIgnoreNextClick = false;
return;
}
const firstIntersectsByLayer = this.getFirstIntersectsOnEachLayer(false);
if (!firstIntersectsByLayer) return;
let closestIntersect;
for (const intersect of Object.values(firstIntersectsByLayer)) {
if (
!closestIntersect ||
intersect.intersect.distance < closestIntersect.intersect.distance
) {
closestIntersect = intersect;
}
}
this._selectedObjectData = closestIntersect;
if (!this._selectedObjectData) return;
if (
this._currentTransformControls &&
this._currentTransformControls.camera ===
this._selectedObjectData.camera
) {
this._currentTransformControls.detach();
this._currentTransformControls.attach(
this._selectedObjectData.intersect.object
);
} else {
if (this._currentTransformControls) {
this._currentTransformControls.detach();
this._currentTransformControls = null;
}
this._currentTransformControls = new THREE_ADDONS.TransformControls(
this._selectedObjectData.camera,
this._game.getRenderer().getCanvas() || undefined
);
this._currentTransformControls.addEventListener('mouseDown', () => {
this._shouldIgnoreNextClick = true;
});
this._currentTransformControls.addEventListener(
'dragging-changed',
(e) => {
if (!this._selectedObjectData) return;
if (e.value) {
// Ignore if the user starts dragging
return;
}
const rendererObject = this._selectedObjectData.intersect.object;
const object = this._game.getObjectFromRenderer(rendererObject);
if (!object) return;
if (object instanceof gdjs.RuntimeObject3D) {
this._game.sendRuntimeObjectsUpdated([
{
object,
position: object
.getRenderer()
.getObjectPositionFrom3DRendererObject(),
},
]);
}
}
);
this._currentTransformControls.scale.y = -1;
this._currentTransformControls.attach(
this._selectedObjectData.intersect.object
);
this._selectedObjectData.scene.add(this._currentTransformControls);
}
}
setupListeners() {
const canvas = this._game.getRenderer().getCanvas();
this._editionAbortController = new AbortController();
canvas?.addEventListener('pointermove', this.onPointerMove.bind(this), {
signal: this._editionAbortController.signal,
});
canvas?.addEventListener('click', this.selectObject.bind(this), {
signal: this._editionAbortController.signal,
});
}
cleanListeners() {
this._editionAbortController.abort();
}
activate(enable: boolean) {
if (enable) this.setupListeners();
else {
this.cleanListeners();
if (this._currentTransformControls) {
this._currentTransformControls.detach();
this._currentTransformControls = null;
}
}
}
reloadInstances(payload: {
layoutName: string;
instances: Array<{
persistentUuid: string;
position: { x: number; y: number; z: number };
}>;
}) {
const currentScene = this._game.getSceneStack().getCurrentScene();
if (!currentScene || currentScene.getName() !== payload.layoutName) {
return;
}
// TODO: Might be worth indexing instances data and runtime objects by their
// persistentUuid (See HotReloader.indexByPersistentUuid).
currentScene.getAdhocListOfAllInstances().forEach((runtimeObject) => {
const instance = payload.instances.find(
(instance) => instance.persistentUuid === runtimeObject.persistentUuid
);
if (instance) {
runtimeObject.setX(instance.position.x);
runtimeObject.setY(instance.position.y);
if (runtimeObject instanceof gdjs.RuntimeObject3D) {
runtimeObject.setZ(instance.position.z);
}
}
});
}
getFirstIntersectsOnEachLayer(highlightObject: boolean) {
if (highlightObject) {
this._clearCurrentPasses.forEach((callback) => callback());
this._clearCurrentPasses.length = 0;
}
const layerNames = new Array();
const currentScene = this._game.getSceneStack().getCurrentScene();
if (!currentScene) return;
const threeRenderer = this._game.getRenderer().getThreeRenderer();
if (!threeRenderer) return;
currentScene.getAllLayerNames(layerNames);
const firstIntersectsByLayer: {
[layerName: string]: {
intersect: THREE.Intersection;
camera: THREE.Camera;
scene: THREE.Scene;
};
} = layerNames.reduce((acc, layerName) => {
const runtimeLayerRender = currentScene
.getLayer(layerName)
.getRenderer();
const threeCamera = runtimeLayerRender.getThreeCamera();
const threeScene = runtimeLayerRender.getThreeScene();
if (!threeCamera || !threeScene) return acc;
if (!this._outlinePasses[layerName]) {
this._outlinePasses[layerName] = new THREE_ADDONS.OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
threeScene,
threeCamera
);
runtimeLayerRender.addPostProcessingPass(this._outlinePasses[layerName]);
}
const outlinePass = this._outlinePasses[layerName];
// Note that raycasting is done by Three.js, which means it could slow down
// if lots of 3D objects are shown. We consider that if this needs improvements,
// this must be handled by the game engine culling
this._raycaster.setFromCamera(this._pointer, threeCamera);
const intersects = this._raycaster.intersectObjects(threeScene.children);
const firstIntersect = intersects.filter((intersect) => {
let isObjectChildOfTransformControls = false;
intersect.object.traverseAncestors((ancestor) => {
if (ancestor === this._currentTransformControls) {
isObjectChildOfTransformControls = true;
}
});
return !isObjectChildOfTransformControls;
})[0];
if (
firstIntersect &&
highlightObject &&
(!this._currentTransformControls ||
!this._currentTransformControls.dragging)
) {
// TODO: OutlinePass currently wrongly highlights the transform controls helper.
// (See https://discourse.threejs.org/t/outlinepass-with-transform-control/18722)
outlinePass.edgeStrength = 6.0;
outlinePass.edgeGlow = 0;
outlinePass.edgeThickness = 1.0;
outlinePass.pulsePeriod = 0;
outlinePass.selectedObjects = [firstIntersect.object];
}
acc[layerName] = {
intersect: firstIntersect,
camera: threeCamera,
scene: threeScene,
};
return acc;
}, {});
return firstIntersectsByLayer;
}
render() {
this.getFirstIntersectsOnEachLayer(true);
}
}
/**
* Represents a game being played.
*/
@@ -169,10 +442,6 @@ namespace gdjs {
//Inputs :
_inputManager: InputManager;
/**
* Allow to specify an external layout to insert in the first scene.
*/
_injectExternalLayout: any;
_options: RuntimeGameOptions;
/**
@@ -187,6 +456,7 @@ namespace gdjs {
_sessionMetricsInitialized: boolean = false;
_disableMetrics: boolean = false;
_isPreview: boolean;
_isInGameEdition: boolean;
/**
* The capture manager, used to manage captures (screenshots, videos, etc...).
@@ -196,12 +466,27 @@ namespace gdjs {
/** True if the RuntimeGame has been disposed and should not be used anymore. */
_wasDisposed: boolean = false;
_editor: RuntimeEditor;
/**
* @param data The object (usually stored in data.json) containing the full project data
* @param
*/
constructor(data: ProjectData, options?: RuntimeGameOptions) {
this._options = options || {};
this._isPreview = this._options.isPreview || false;
if (this._isPreview) {
// Check if we need to restore the state from the URL, which is used
// when a preview is hard reloaded (search for `hardReload`).
const runtimeGameStatusFromUrl = readRuntimeGameStatusFromUrl();
if (runtimeGameStatusFromUrl) {
this._options.initialRuntimeGameStatus = runtimeGameStatusFromUrl;
}
}
this._isInGameEdition =
this._options.initialRuntimeGameStatus?.isInGameEdition || false;
this._variables = new gdjs.VariablesContainer(data.variables);
this._variablesByExtensionName = new Map<
string,
@@ -225,7 +510,7 @@ namespace gdjs {
getGlobalResourceNames(data),
data.layouts
);
this._editor = new RuntimeEditor(this);
this._effectsManager = new gdjs.EffectsManager();
this._maxFPS = this._data.properties.maxFPS;
this._minFPS = this._data.properties.minFPS;
@@ -250,7 +535,6 @@ namespace gdjs {
);
this._sceneStack = new gdjs.SceneStack(this);
this._inputManager = new gdjs.InputManager();
this._injectExternalLayout = this._options.injectExternalLayout || '';
this._debuggerClient = gdjs.DebuggerClient
? new gdjs.DebuggerClient(this)
: null;
@@ -260,7 +544,6 @@ namespace gdjs {
this._options.captureOptions || {}
)
: null;
this._isPreview = this._options.isPreview || false;
this._sessionId = null;
this._playerId = null;
@@ -709,9 +992,9 @@ namespace gdjs {
if (this._paused === enable) return;
this._paused = enable;
this._editor.activate(enable);
if (this._debuggerClient) {
if (this._paused) this._debuggerClient.sendGamePaused();
else this._debuggerClient.sendGameResumed();
this._debuggerClient.sendRuntimeGameStatus();
}
}
@@ -872,11 +1155,16 @@ namespace gdjs {
await loadAssets(onProgress);
await loadingScreen.unload();
this.pause(false);
if (!this._isInGameEdition) {
this.pause(false);
}
}
private _getFirstSceneName(): string {
const firstSceneName = this._data.firstLayout;
const firstSceneName =
this._options.initialRuntimeGameStatus?.sceneName ||
this._data.firstLayout;
return this.hasScene(firstSceneName)
? firstSceneName
: // There is always at least a scene
@@ -896,10 +1184,15 @@ namespace gdjs {
this._forceGameResolutionUpdate();
// Load the first scene
this._sceneStack.push(
this._getFirstSceneName(),
this._injectExternalLayout
);
this._sceneStack.push({
sceneName: this._getFirstSceneName(),
externalLayoutName:
this._options.initialRuntimeGameStatus
?.injectedExternalLayoutName || undefined,
skipCreatingInstancesFromScene:
this._options.initialRuntimeGameStatus
?.skipCreatingInstancesFromScene || false,
});
this._watermark.displayAtStartup();
//Uncomment to profile the first x frames of the game.
@@ -917,11 +1210,29 @@ namespace gdjs {
this._setupGameVisibilityEvents();
// The standard game loop
let lastFrameSceneName: string | null = null;
let accumulatedElapsedTime = 0;
this._hasJustResumed = false;
this._renderer.startGameLoop((lastCallElapsedTime) => {
try {
if (this._debuggerClient) {
// Watch the scene name to automatically update debugger when a scene is changed.
const currentScene = this.getSceneStack().getCurrentScene();
if (
currentScene &&
currentScene.getName() !== lastFrameSceneName
) {
lastFrameSceneName = currentScene.getName();
this._debuggerClient.sendRuntimeGameStatus();
}
}
if (this._paused) {
this._editor.render();
// The game is paused for edition: the game loop continues to run,
// but the game logic is not executed.
this._sceneStack.renderWithoutStep();
return true;
}
@@ -1273,6 +1584,22 @@ namespace gdjs {
return this._isPreview;
}
/**
* Check if the game loop is paused, for debugging/edition purposes.
* @returns true if the current game is paused
*/
isPaused(): boolean {
return this._paused;
}
/**
* Check if the game should display in-game edition tools or not.
* @returns true if the current game is being edited.
*/
isInGameEdition(): boolean {
return this._isInGameEdition;
}
/**
* Check if the game should call GDevelop development APIs or not.
*
@@ -1319,6 +1646,38 @@ namespace gdjs {
: embeddedResourceName;
}
getObjectFromRenderer(
renderer: THREE.Object3D | gdjs.RendererObjectInterface
): RuntimeObject | null {
const currentScene = this.getSceneStack().getCurrentScene();
if (!currentScene) return null;
// TODO: Should we instead have an index on the runtime object persistentUuid?
// In that case, it would require to store the persistentUuid on the renderer object.
const allInstances = currentScene.getAdhocListOfAllInstances();
const object = allInstances.find(
renderer instanceof THREE.Object3D
? (instance) => instance.get3DRendererObject() === renderer
: (instance) => instance.getRendererObject() === renderer
);
return object || null;
}
sendRuntimeObjectsUpdated(
runtimeObjectUpdates: Array<{
object: RuntimeObject3D;
position: { x: number; y: number; z: number };
}>
) {
if (!this._debuggerClient) return;
const currentScene = this.getSceneStack().getCurrentScene();
if (!currentScene) return;
this._debuggerClient.sendInstancesUpdated(
runtimeObjectUpdates,
currentScene.getName()
);
}
/**
* Returns the array of resources that are embedded to passed one.
* @param resourceName The name of resource to find embedded resources of.

View File

@@ -121,10 +121,15 @@ namespace gdjs {
/**
* Load the runtime scene from the given scene.
* @param sceneData An object containing the scene data.
*
* @param sceneAndExtensionsData The data of the scene and extension variables to be loaded.
* @param options Options to change what is loaded.
* @see gdjs.RuntimeGame#getSceneAndExtensionsData
*/
loadFromScene(sceneAndExtensionsData: SceneAndExtensionsData | null) {
loadFromScene(
sceneAndExtensionsData: SceneAndExtensionsData | null,
options?: { skipCreatingInstances?: boolean }
) {
if (!sceneAndExtensionsData) {
logger.error('loadFromScene was called without a scene');
return;
@@ -184,14 +189,16 @@ namespace gdjs {
}
//Create initial instances of objects
this.createObjectsFrom(
sceneData.instances,
0,
0,
0,
/*trackByPersistentUuid=*/
true
);
if (!options || !options.skipCreatingInstances) {
this.createObjectsFrom(
sceneData.instances,
0,
0,
0,
/*trackByPersistentUuid=*/
true
);
}
// Set up the default z order (for objects created from events)
this._setLayerDefaultZOrders();
@@ -358,7 +365,7 @@ namespace gdjs {
}
/**
* Step and render the scene.
* Step (execute the game logic) and render the scene.
* @param elapsedTime In milliseconds
* @return true if the game loop should continue, false if a scene change/push/pop
* or a game stop was requested.
@@ -418,6 +425,21 @@ namespace gdjs {
if (this._profiler) {
this._profiler.end('callbacks and extensions (post-events)');
}
this.render();
this._isJustResumed = false;
if (this._profiler) {
this._profiler.end('render');
}
if (this._profiler) {
this._profiler.endFrame();
}
return !!this.getRequestedChange();
}
/**
* Render the scene (but do not execute the game logic).
*/
render() {
if (this._profiler) {
this._profiler.begin('objects (pre-render, effects update)');
}
@@ -447,21 +469,6 @@ namespace gdjs {
);
}
this._isJustResumed = false;
this.render();
if (this._profiler) {
this._profiler.end('render');
}
if (this._profiler) {
this._profiler.endFrame();
}
return !!this.getRequestedChange();
}
/**
* Render the PIXI container associated to the runtimeScene.
*/
render() {
this._renderer.render();
}

View File

@@ -2,6 +2,16 @@ namespace gdjs {
const logger = new gdjs.Logger('Scene stack');
const debugLogger = new gdjs.Logger('Multiplayer - Debug');
interface PushSceneOptions {
sceneName: string;
externalLayoutName?: string;
skipCreatingInstancesFromScene?: boolean;
};
interface ReplaceSceneOptions extends PushSceneOptions {
clear: boolean;
};
/**
* Hold the stack of scenes ({@link gdjs.RuntimeScene}) being played.
*/
@@ -113,15 +123,28 @@ namespace gdjs {
}
}
/**
* Pause the scene currently being played and start the new scene that is specified.
* If `externalLayoutName` is set, also instantiate the objects from this external layout.
* Pause the scene currently being played and start the new scene that is specified in `options.sceneName`.
* If `options.externalLayoutName` is set, also instantiate the objects from this external layout.
*
* @param options Contains the scene name and optional external layout name to instantiate.
* @param deprecatedExternalLayoutName Deprecated, use `options.externalLayoutName` instead.
*/
push(
newSceneName: string,
externalLayoutName?: string
options: PushSceneOptions | string,
deprecatedExternalLayoutName?: string
): gdjs.RuntimeScene | null {
this._throwIfDisposed();
console.log({options, deprecatedExternalLayoutName})
const sceneName =
typeof options === 'string' ? options : options.sceneName;
const skipCreatingInstancesFromScene =
typeof options === 'string' ? false : options.skipCreatingInstancesFromScene;
const externalLayoutName =
deprecatedExternalLayoutName ||
(typeof options === 'string' ? undefined : options.externalLayoutName);
// Tell the scene it's being paused
const currentScene = this._stack[this._stack.length - 1];
@@ -131,35 +154,43 @@ namespace gdjs {
// Avoid a risk of displaying an intermediate loading screen
// during 1 frame.
if (this._runtimeGame.areSceneAssetsReady(newSceneName)) {
return this._loadNewScene(newSceneName, externalLayoutName);
if (this._runtimeGame.areSceneAssetsReady(sceneName)) {
return this._loadNewScene({
sceneName,
externalLayoutName,
skipCreatingInstancesFromScene,
});
}
this._isNextLayoutLoading = true;
this._runtimeGame.loadSceneAssets(newSceneName).then(() => {
this._loadNewScene(newSceneName);
this._runtimeGame.loadSceneAssets(sceneName).then(() => {
this._loadNewScene({
sceneName,
externalLayoutName,
skipCreatingInstancesFromScene,
});
this._isNextLayoutLoading = false;
});
return null;
}
private _loadNewScene(
newSceneName: string,
externalLayoutName?: string
): gdjs.RuntimeScene {
private _loadNewScene(options: PushSceneOptions): gdjs.RuntimeScene {
this._throwIfDisposed();
// Load the new one
const newScene = new gdjs.RuntimeScene(this._runtimeGame);
newScene.loadFromScene(
this._runtimeGame.getSceneAndExtensionsData(newSceneName)
this._runtimeGame.getSceneAndExtensionsData(options.sceneName),
{
skipCreatingInstances: options.skipCreatingInstancesFromScene,
}
);
this._wasFirstSceneLoaded = true;
// Optionally create the objects from an external layout.
if (externalLayoutName) {
if (options.externalLayoutName) {
const externalLayoutData = this._runtimeGame.getExternalLayoutData(
externalLayoutName
options.externalLayoutName
);
if (externalLayoutData) {
newScene.createObjectsFrom(
@@ -177,10 +208,15 @@ namespace gdjs {
}
/**
* Start the specified scene, replacing the one currently being played.
* If `clear` is set to true, all running scenes are also removed from the stack of scenes.
* Start the scene in `options.sceneName`, replacing the one currently being played.
* If `options.clear` is set to true, all running scenes are also removed from the stack of scenes.
*
* @param options Contains the scene name and optional external layout name to instantiate.
* @param deprecatedClear Deprecated, use `options.clear` instead.
*/
replace(newSceneName: string, clear?: boolean): gdjs.RuntimeScene | null {
replace(options: ReplaceSceneOptions | string, deprecatedClear?: boolean): gdjs.RuntimeScene | null {
const clear = deprecatedClear || typeof options === 'string' ? false : options.clear;
this._throwIfDisposed();
if (!!clear) {
// Unload all the scenes
@@ -199,7 +235,7 @@ namespace gdjs {
}
}
}
return this.push(newSceneName);
return this.push(options);
}
/**

View File

@@ -2,7 +2,9 @@ import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass';
import { Pass } from 'three/examples/jsm/postprocessing/Pass';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';
@@ -21,7 +23,9 @@ declare global {
GLTF,
DRACOLoader,
SkeletonUtils,
TransformControls,
EffectComposer,
OutlinePass,
Pass,
RenderPass,
ShaderPass,

View File

@@ -24,7 +24,8 @@ describe('gdjs.RuntimeGameRenderer canvas tests', () => {
it('should correctly initialize external canvas and create domElementsContainer', () => {
const gameCanvas = document.createElement('canvas');
gameContainer.appendChild(gameCanvas);
renderer.initializeForCanvas(gameCanvas);
renderer.initializeRenderers(gameCanvas);
renderer.initializeCanvas(gameCanvas);
const actualGameCanvas = renderer.getCanvas();
const actualDomElementsContainer = renderer.getDomElementContainer();

View File

@@ -1373,6 +1373,7 @@ interface InitialInstance {
double GetCustomDepth();
[Ref] InitialInstance ResetPersistentUuid();
[Const, Value] DOMString GetPersistentUuid();
void UpdateCustomProperty(
[Const] DOMString name,
@@ -3843,6 +3844,7 @@ interface PreviewExportOptions {
[Ref] PreviewExportOptions SetNativeMobileApp(boolean enable);
[Ref] PreviewExportOptions SetFullLoadingScreen(boolean enable);
[Ref] PreviewExportOptions SetIsDevelopmentEnvironment(boolean enable);
[Ref] PreviewExportOptions SetIsInGameEdition(boolean enable);
[Ref] PreviewExportOptions SetNonRuntimeScriptsCacheBurst(unsigned long value);
[Ref] PreviewExportOptions SetElectronRemoteRequirePath([Const] DOMString electronRemoteRequirePath);
[Ref] PreviewExportOptions SetGDevelopResourceToken([Const] DOMString gdevelopResourceToken);

View File

@@ -1145,6 +1145,7 @@ export class InitialInstance extends EmscriptenObject {
setCustomDepth(depth: number): void;
getCustomDepth(): number;
resetPersistentUuid(): InitialInstance;
getPersistentUuid(): string;
updateCustomProperty(name: string, value: string, globalObjectsContainer: ObjectsContainer, objectsContainer: ObjectsContainer): void;
getCustomProperties(globalObjectsContainer: ObjectsContainer, objectsContainer: ObjectsContainer): MapStringPropertyDescriptor;
getRawDoubleProperty(name: string): number;
@@ -2850,6 +2851,7 @@ export class PreviewExportOptions extends EmscriptenObject {
setNativeMobileApp(enable: boolean): PreviewExportOptions;
setFullLoadingScreen(enable: boolean): PreviewExportOptions;
setIsDevelopmentEnvironment(enable: boolean): PreviewExportOptions;
setIsInGameEdition(enable: boolean): PreviewExportOptions;
setNonRuntimeScriptsCacheBurst(value: number): PreviewExportOptions;
setElectronRemoteRequirePath(electronRemoteRequirePath: string): PreviewExportOptions;
setGDevelopResourceToken(gdevelopResourceToken: string): PreviewExportOptions;

View File

@@ -44,6 +44,7 @@ declare class gdInitialInstance {
setCustomDepth(depth: number): void;
getCustomDepth(): number;
resetPersistentUuid(): gdInitialInstance;
getPersistentUuid(): string;
updateCustomProperty(name: string, value: string, globalObjectsContainer: gdObjectsContainer, objectsContainer: gdObjectsContainer): void;
getCustomProperties(globalObjectsContainer: gdObjectsContainer, objectsContainer: gdObjectsContainer): gdMapStringPropertyDescriptor;
getRawDoubleProperty(name: string): number;

View File

@@ -13,6 +13,7 @@ declare class gdPreviewExportOptions {
setNativeMobileApp(enable: boolean): gdPreviewExportOptions;
setFullLoadingScreen(enable: boolean): gdPreviewExportOptions;
setIsDevelopmentEnvironment(enable: boolean): gdPreviewExportOptions;
setIsInGameEdition(enable: boolean): gdPreviewExportOptions;
setNonRuntimeScriptsCacheBurst(value: number): gdPreviewExportOptions;
setElectronRemoteRequirePath(electronRemoteRequirePath: string): gdPreviewExportOptions;
setGDevelopResourceToken(gdevelopResourceToken: string): gdPreviewExportOptions;

View File

@@ -30,17 +30,17 @@ GDevelop is a **full-featured, no-code, open-source** game development software.
GDevelop is composed of an **editor**, a **game engine**, an **ecosystem** of extensions as well as **online services** and commercial support.
| Directory | Description |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Core` | Core classes, describing the structure of a game and tools to implement the IDE and work with GDevelop games. |
| `GDJS` | The game engine, written in TypeScript, using PixiJS and Three.js (WebGL), powering all GDevelop games. |
| `GDevelop.js` | Bindings of `Core`, `GDJS` and `Extensions` to JavaScript (with WebAssembly), used by the IDE. |
| `newIDE` | The game editor, written in JavaScript with React, Electron, PixiJS and Three.js. |
| `Extensions` | Built-in extensions for the game engine, providing objects, behaviors, events and new features. All the [community extensions are on this repository](https://github.com/GDevelopApp/GDevelop-extensions). |
| Directory | Description |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Core` | Core classes, describing the structure of a game and tools to implement the IDE and work with GDevelop games. |
| `GDJS` | The game engine, written in TypeScript, using PixiJS and Three.js for 2D and 3D rendering (WebGL), powering all GDevelop games. |
| `GDevelop.js` | Bindings of `Core`, `GDJS` and `Extensions` to JavaScript (with WebAssembly), used by the IDE. |
| `newIDE` | The game editor, written in JavaScript with React, Electron, PixiJS and Three.js.js. |
| `Extensions` | Built-in extensions for the game engine, providing objects, behaviors and new features. For example, this includes the physics engines running in WebAssembly (Box2D or Jolt Physics for 3D). All the [community extensions are on this repository](https://github.com/GDevelopApp/GDevelop-extensions). |
To learn more about GDevelop Architecture, read the [architecture overview here](Core/GDevelop-Architecture-Overview.md).
Pre-generated documentation of the Core library, C++ and TypeScript game engines is [available here](https://docs.gdevelop.io).
Pre-generated documentation of the game engine is [available here](https://docs.gdevelop.io).
Status of the tests and builds: [![macOS and Linux build status](https://circleci.com/gh/4ian/GDevelop.svg?style=shield)](https://app.circleci.com/pipelines/github/4ian/GDevelop) [![Fast tests status](https://gdevelop.semaphoreci.com/badges/GDevelop/branches/master.svg?style=shields)](https://gdevelop.semaphoreci.com/projects/GDevelop) [![Windows Build status](https://ci.appveyor.com/api/projects/status/84uhtdox47xp422x/branch/master?svg=true)](https://ci.appveyor.com/project/4ian/gdevelop/branch/master) [![https://good-labs.github.io/greater-good-affirmation/assets/images/badge.svg](https://good-labs.github.io/greater-good-affirmation/assets/images/badge.svg)](https://good-labs.github.io/greater-good-affirmation)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,654 @@
import {
AdditiveBlending,
Color,
DoubleSide,
HalfFloatType,
Matrix4,
MeshDepthMaterial,
NoBlending,
RGBADepthPacking,
ShaderMaterial,
UniformsUtils,
Vector2,
Vector3,
WebGLRenderTarget
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
import { CopyShader } from '../shaders/CopyShader.js';
class OutlinePass extends Pass {
constructor( resolution, scene, camera, selectedObjects ) {
super();
this.renderScene = scene;
this.renderCamera = camera;
this.selectedObjects = selectedObjects !== undefined ? selectedObjects : [];
this.visibleEdgeColor = new Color( 1, 1, 1 );
this.hiddenEdgeColor = new Color( 0.1, 0.04, 0.02 );
this.edgeGlow = 0.0;
this.usePatternTexture = false;
this.edgeThickness = 1.0;
this.edgeStrength = 3.0;
this.downSampleRatio = 2;
this.pulsePeriod = 0;
this._visibilityCache = new Map();
this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 );
const resx = Math.round( this.resolution.x / this.downSampleRatio );
const resy = Math.round( this.resolution.y / this.downSampleRatio );
this.renderTargetMaskBuffer = new WebGLRenderTarget( this.resolution.x, this.resolution.y );
this.renderTargetMaskBuffer.texture.name = 'OutlinePass.mask';
this.renderTargetMaskBuffer.texture.generateMipmaps = false;
this.depthMaterial = new MeshDepthMaterial();
this.depthMaterial.side = DoubleSide;
this.depthMaterial.depthPacking = RGBADepthPacking;
this.depthMaterial.blending = NoBlending;
this.prepareMaskMaterial = this.getPrepareMaskMaterial();
this.prepareMaskMaterial.side = DoubleSide;
this.prepareMaskMaterial.fragmentShader = replaceDepthToViewZ( this.prepareMaskMaterial.fragmentShader, this.renderCamera );
this.renderTargetDepthBuffer = new WebGLRenderTarget( this.resolution.x, this.resolution.y, { type: HalfFloatType } );
this.renderTargetDepthBuffer.texture.name = 'OutlinePass.depth';
this.renderTargetDepthBuffer.texture.generateMipmaps = false;
this.renderTargetMaskDownSampleBuffer = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetMaskDownSampleBuffer.texture.name = 'OutlinePass.depthDownSample';
this.renderTargetMaskDownSampleBuffer.texture.generateMipmaps = false;
this.renderTargetBlurBuffer1 = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetBlurBuffer1.texture.name = 'OutlinePass.blur1';
this.renderTargetBlurBuffer1.texture.generateMipmaps = false;
this.renderTargetBlurBuffer2 = new WebGLRenderTarget( Math.round( resx / 2 ), Math.round( resy / 2 ), { type: HalfFloatType } );
this.renderTargetBlurBuffer2.texture.name = 'OutlinePass.blur2';
this.renderTargetBlurBuffer2.texture.generateMipmaps = false;
this.edgeDetectionMaterial = this.getEdgeDetectionMaterial();
this.renderTargetEdgeBuffer1 = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetEdgeBuffer1.texture.name = 'OutlinePass.edge1';
this.renderTargetEdgeBuffer1.texture.generateMipmaps = false;
this.renderTargetEdgeBuffer2 = new WebGLRenderTarget( Math.round( resx / 2 ), Math.round( resy / 2 ), { type: HalfFloatType } );
this.renderTargetEdgeBuffer2.texture.name = 'OutlinePass.edge2';
this.renderTargetEdgeBuffer2.texture.generateMipmaps = false;
const MAX_EDGE_THICKNESS = 4;
const MAX_EDGE_GLOW = 4;
this.separableBlurMaterial1 = this.getSeperableBlurMaterial( MAX_EDGE_THICKNESS );
this.separableBlurMaterial1.uniforms[ 'texSize' ].value.set( resx, resy );
this.separableBlurMaterial1.uniforms[ 'kernelRadius' ].value = 1;
this.separableBlurMaterial2 = this.getSeperableBlurMaterial( MAX_EDGE_GLOW );
this.separableBlurMaterial2.uniforms[ 'texSize' ].value.set( Math.round( resx / 2 ), Math.round( resy / 2 ) );
this.separableBlurMaterial2.uniforms[ 'kernelRadius' ].value = MAX_EDGE_GLOW;
// Overlay material
this.overlayMaterial = this.getOverlayMaterial();
// copy material
const copyShader = CopyShader;
this.copyUniforms = UniformsUtils.clone( copyShader.uniforms );
this.materialCopy = new ShaderMaterial( {
uniforms: this.copyUniforms,
vertexShader: copyShader.vertexShader,
fragmentShader: copyShader.fragmentShader,
blending: NoBlending,
depthTest: false,
depthWrite: false
} );
this.enabled = true;
this.needsSwap = false;
this._oldClearColor = new Color();
this.oldClearAlpha = 1;
this.fsQuad = new FullScreenQuad( null );
this.tempPulseColor1 = new Color();
this.tempPulseColor2 = new Color();
this.textureMatrix = new Matrix4();
function replaceDepthToViewZ( string, camera ) {
const type = camera.isPerspectiveCamera ? 'perspective' : 'orthographic';
return string.replace( /DEPTH_TO_VIEW_Z/g, type + 'DepthToViewZ' );
}
}
dispose() {
this.renderTargetMaskBuffer.dispose();
this.renderTargetDepthBuffer.dispose();
this.renderTargetMaskDownSampleBuffer.dispose();
this.renderTargetBlurBuffer1.dispose();
this.renderTargetBlurBuffer2.dispose();
this.renderTargetEdgeBuffer1.dispose();
this.renderTargetEdgeBuffer2.dispose();
this.depthMaterial.dispose();
this.prepareMaskMaterial.dispose();
this.edgeDetectionMaterial.dispose();
this.separableBlurMaterial1.dispose();
this.separableBlurMaterial2.dispose();
this.overlayMaterial.dispose();
this.materialCopy.dispose();
this.fsQuad.dispose();
}
setSize( width, height ) {
this.renderTargetMaskBuffer.setSize( width, height );
this.renderTargetDepthBuffer.setSize( width, height );
let resx = Math.round( width / this.downSampleRatio );
let resy = Math.round( height / this.downSampleRatio );
this.renderTargetMaskDownSampleBuffer.setSize( resx, resy );
this.renderTargetBlurBuffer1.setSize( resx, resy );
this.renderTargetEdgeBuffer1.setSize( resx, resy );
this.separableBlurMaterial1.uniforms[ 'texSize' ].value.set( resx, resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
this.renderTargetBlurBuffer2.setSize( resx, resy );
this.renderTargetEdgeBuffer2.setSize( resx, resy );
this.separableBlurMaterial2.uniforms[ 'texSize' ].value.set( resx, resy );
}
changeVisibilityOfSelectedObjects( bVisible ) {
const cache = this._visibilityCache;
function gatherSelectedMeshesCallBack( object ) {
if ( object.isMesh ) {
if ( bVisible === true ) {
object.visible = cache.get( object );
} else {
cache.set( object, object.visible );
object.visible = bVisible;
}
}
}
for ( let i = 0; i < this.selectedObjects.length; i ++ ) {
const selectedObject = this.selectedObjects[ i ];
selectedObject.traverse( gatherSelectedMeshesCallBack );
}
}
changeVisibilityOfNonSelectedObjects( bVisible ) {
const cache = this._visibilityCache;
const selectedMeshes = [];
function gatherSelectedMeshesCallBack( object ) {
if ( object.isMesh ) selectedMeshes.push( object );
}
for ( let i = 0; i < this.selectedObjects.length; i ++ ) {
const selectedObject = this.selectedObjects[ i ];
selectedObject.traverse( gatherSelectedMeshesCallBack );
}
function VisibilityChangeCallBack( object ) {
if ( object.isMesh || object.isSprite ) {
// only meshes and sprites are supported by OutlinePass
let bFound = false;
for ( let i = 0; i < selectedMeshes.length; i ++ ) {
const selectedObjectId = selectedMeshes[ i ].id;
if ( selectedObjectId === object.id ) {
bFound = true;
break;
}
}
if ( bFound === false ) {
const visibility = object.visible;
if ( bVisible === false || cache.get( object ) === true ) {
object.visible = bVisible;
}
cache.set( object, visibility );
}
} else if ( object.isPoints || object.isLine ) {
// the visibilty of points and lines is always set to false in order to
// not affect the outline computation
if ( bVisible === true ) {
object.visible = cache.get( object ); // restore
} else {
cache.set( object, object.visible );
object.visible = bVisible;
}
}
}
this.renderScene.traverse( VisibilityChangeCallBack );
}
updateTextureMatrix() {
this.textureMatrix.set( 0.5, 0.0, 0.0, 0.5,
0.0, 0.5, 0.0, 0.5,
0.0, 0.0, 0.5, 0.5,
0.0, 0.0, 0.0, 1.0 );
this.textureMatrix.multiply( this.renderCamera.projectionMatrix );
this.textureMatrix.multiply( this.renderCamera.matrixWorldInverse );
}
render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) {
if ( this.selectedObjects.length > 0 ) {
renderer.getClearColor( this._oldClearColor );
this.oldClearAlpha = renderer.getClearAlpha();
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
if ( maskActive ) renderer.state.buffers.stencil.setTest( false );
renderer.setClearColor( 0xffffff, 1 );
// Make selected objects invisible
this.changeVisibilityOfSelectedObjects( false );
const currentBackground = this.renderScene.background;
this.renderScene.background = null;
// 1. Draw Non Selected objects in the depth buffer
this.renderScene.overrideMaterial = this.depthMaterial;
renderer.setRenderTarget( this.renderTargetDepthBuffer );
renderer.clear();
renderer.render( this.renderScene, this.renderCamera );
// Make selected objects visible
this.changeVisibilityOfSelectedObjects( true );
this._visibilityCache.clear();
// Update Texture Matrix for Depth compare
this.updateTextureMatrix();
// Make non selected objects invisible, and draw only the selected objects, by comparing the depth buffer of non selected objects
this.changeVisibilityOfNonSelectedObjects( false );
this.renderScene.overrideMaterial = this.prepareMaskMaterial;
this.prepareMaskMaterial.uniforms[ 'cameraNearFar' ].value.set( this.renderCamera.near, this.renderCamera.far );
this.prepareMaskMaterial.uniforms[ 'depthTexture' ].value = this.renderTargetDepthBuffer.texture;
this.prepareMaskMaterial.uniforms[ 'textureMatrix' ].value = this.textureMatrix;
renderer.setRenderTarget( this.renderTargetMaskBuffer );
renderer.clear();
renderer.render( this.renderScene, this.renderCamera );
this.renderScene.overrideMaterial = null;
this.changeVisibilityOfNonSelectedObjects( true );
this._visibilityCache.clear();
this.renderScene.background = currentBackground;
// 2. Downsample to Half resolution
this.fsQuad.material = this.materialCopy;
this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetMaskBuffer.texture;
renderer.setRenderTarget( this.renderTargetMaskDownSampleBuffer );
renderer.clear();
this.fsQuad.render( renderer );
this.tempPulseColor1.copy( this.visibleEdgeColor );
this.tempPulseColor2.copy( this.hiddenEdgeColor );
if ( this.pulsePeriod > 0 ) {
const scalar = ( 1 + 0.25 ) / 2 + Math.cos( performance.now() * 0.01 / this.pulsePeriod ) * ( 1.0 - 0.25 ) / 2;
this.tempPulseColor1.multiplyScalar( scalar );
this.tempPulseColor2.multiplyScalar( scalar );
}
// 3. Apply Edge Detection Pass
this.fsQuad.material = this.edgeDetectionMaterial;
this.edgeDetectionMaterial.uniforms[ 'maskTexture' ].value = this.renderTargetMaskDownSampleBuffer.texture;
this.edgeDetectionMaterial.uniforms[ 'texSize' ].value.set( this.renderTargetMaskDownSampleBuffer.width, this.renderTargetMaskDownSampleBuffer.height );
this.edgeDetectionMaterial.uniforms[ 'visibleEdgeColor' ].value = this.tempPulseColor1;
this.edgeDetectionMaterial.uniforms[ 'hiddenEdgeColor' ].value = this.tempPulseColor2;
renderer.setRenderTarget( this.renderTargetEdgeBuffer1 );
renderer.clear();
this.fsQuad.render( renderer );
// 4. Apply Blur on Half res
this.fsQuad.material = this.separableBlurMaterial1;
this.separableBlurMaterial1.uniforms[ 'colorTexture' ].value = this.renderTargetEdgeBuffer1.texture;
this.separableBlurMaterial1.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionX;
this.separableBlurMaterial1.uniforms[ 'kernelRadius' ].value = this.edgeThickness;
renderer.setRenderTarget( this.renderTargetBlurBuffer1 );
renderer.clear();
this.fsQuad.render( renderer );
this.separableBlurMaterial1.uniforms[ 'colorTexture' ].value = this.renderTargetBlurBuffer1.texture;
this.separableBlurMaterial1.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionY;
renderer.setRenderTarget( this.renderTargetEdgeBuffer1 );
renderer.clear();
this.fsQuad.render( renderer );
// Apply Blur on quarter res
this.fsQuad.material = this.separableBlurMaterial2;
this.separableBlurMaterial2.uniforms[ 'colorTexture' ].value = this.renderTargetEdgeBuffer1.texture;
this.separableBlurMaterial2.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionX;
renderer.setRenderTarget( this.renderTargetBlurBuffer2 );
renderer.clear();
this.fsQuad.render( renderer );
this.separableBlurMaterial2.uniforms[ 'colorTexture' ].value = this.renderTargetBlurBuffer2.texture;
this.separableBlurMaterial2.uniforms[ 'direction' ].value = OutlinePass.BlurDirectionY;
renderer.setRenderTarget( this.renderTargetEdgeBuffer2 );
renderer.clear();
this.fsQuad.render( renderer );
// Blend it additively over the input texture
this.fsQuad.material = this.overlayMaterial;
this.overlayMaterial.uniforms[ 'maskTexture' ].value = this.renderTargetMaskBuffer.texture;
this.overlayMaterial.uniforms[ 'edgeTexture1' ].value = this.renderTargetEdgeBuffer1.texture;
this.overlayMaterial.uniforms[ 'edgeTexture2' ].value = this.renderTargetEdgeBuffer2.texture;
this.overlayMaterial.uniforms[ 'patternTexture' ].value = this.patternTexture;
this.overlayMaterial.uniforms[ 'edgeStrength' ].value = this.edgeStrength;
this.overlayMaterial.uniforms[ 'edgeGlow' ].value = this.edgeGlow;
this.overlayMaterial.uniforms[ 'usePatternTexture' ].value = this.usePatternTexture;
if ( maskActive ) renderer.state.buffers.stencil.setTest( true );
renderer.setRenderTarget( readBuffer );
this.fsQuad.render( renderer );
renderer.setClearColor( this._oldClearColor, this.oldClearAlpha );
renderer.autoClear = oldAutoClear;
}
if ( this.renderToScreen ) {
this.fsQuad.material = this.materialCopy;
this.copyUniforms[ 'tDiffuse' ].value = readBuffer.texture;
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
}
}
getPrepareMaskMaterial() {
return new ShaderMaterial( {
uniforms: {
'depthTexture': { value: null },
'cameraNearFar': { value: new Vector2( 0.5, 0.5 ) },
'textureMatrix': { value: null }
},
vertexShader:
`#include <morphtarget_pars_vertex>
#include <skinning_pars_vertex>
varying vec4 projTexCoord;
varying vec4 vPosition;
uniform mat4 textureMatrix;
void main() {
#include <skinbase_vertex>
#include <begin_vertex>
#include <morphtarget_vertex>
#include <skinning_vertex>
#include <project_vertex>
vPosition = mvPosition;
vec4 worldPosition = vec4( transformed, 1.0 );
#ifdef USE_INSTANCING
worldPosition = instanceMatrix * worldPosition;
#endif
worldPosition = modelMatrix * worldPosition;
projTexCoord = textureMatrix * worldPosition;
}`,
fragmentShader:
`#include <packing>
varying vec4 vPosition;
varying vec4 projTexCoord;
uniform sampler2D depthTexture;
uniform vec2 cameraNearFar;
void main() {
float depth = unpackRGBAToDepth(texture2DProj( depthTexture, projTexCoord ));
float viewZ = - DEPTH_TO_VIEW_Z( depth, cameraNearFar.x, cameraNearFar.y );
float depthTest = (-vPosition.z > viewZ) ? 1.0 : 0.0;
gl_FragColor = vec4(0.0, depthTest, 1.0, 1.0);
}`
} );
}
getEdgeDetectionMaterial() {
return new ShaderMaterial( {
uniforms: {
'maskTexture': { value: null },
'texSize': { value: new Vector2( 0.5, 0.5 ) },
'visibleEdgeColor': { value: new Vector3( 1.0, 1.0, 1.0 ) },
'hiddenEdgeColor': { value: new Vector3( 1.0, 1.0, 1.0 ) },
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`varying vec2 vUv;
uniform sampler2D maskTexture;
uniform vec2 texSize;
uniform vec3 visibleEdgeColor;
uniform vec3 hiddenEdgeColor;
void main() {
vec2 invSize = 1.0 / texSize;
vec4 uvOffset = vec4(1.0, 0.0, 0.0, 1.0) * vec4(invSize, invSize);
vec4 c1 = texture2D( maskTexture, vUv + uvOffset.xy);
vec4 c2 = texture2D( maskTexture, vUv - uvOffset.xy);
vec4 c3 = texture2D( maskTexture, vUv + uvOffset.yw);
vec4 c4 = texture2D( maskTexture, vUv - uvOffset.yw);
float diff1 = (c1.r - c2.r)*0.5;
float diff2 = (c3.r - c4.r)*0.5;
float d = length( vec2(diff1, diff2) );
float a1 = min(c1.g, c2.g);
float a2 = min(c3.g, c4.g);
float visibilityFactor = min(a1, a2);
vec3 edgeColor = 1.0 - visibilityFactor > 0.001 ? visibleEdgeColor : hiddenEdgeColor;
gl_FragColor = vec4(edgeColor, 1.0) * vec4(d);
}`
} );
}
getSeperableBlurMaterial( maxRadius ) {
return new ShaderMaterial( {
defines: {
'MAX_RADIUS': maxRadius,
},
uniforms: {
'colorTexture': { value: null },
'texSize': { value: new Vector2( 0.5, 0.5 ) },
'direction': { value: new Vector2( 0.5, 0.5 ) },
'kernelRadius': { value: 1.0 }
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 texSize;
uniform vec2 direction;
uniform float kernelRadius;
float gaussianPdf(in float x, in float sigma) {
return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;
}
void main() {
vec2 invSize = 1.0 / texSize;
float sigma = kernelRadius/2.0;
float weightSum = gaussianPdf(0.0, sigma);
vec4 diffuseSum = texture2D( colorTexture, vUv) * weightSum;
vec2 delta = direction * invSize * kernelRadius/float(MAX_RADIUS);
vec2 uvOffset = delta;
for( int i = 1; i <= MAX_RADIUS; i ++ ) {
float x = kernelRadius * float(i) / float(MAX_RADIUS);
float w = gaussianPdf(x, sigma);
vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);
vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);
diffuseSum += ((sample1 + sample2) * w);
weightSum += (2.0 * w);
uvOffset += delta;
}
gl_FragColor = diffuseSum/weightSum;
}`
} );
}
getOverlayMaterial() {
return new ShaderMaterial( {
uniforms: {
'maskTexture': { value: null },
'edgeTexture1': { value: null },
'edgeTexture2': { value: null },
'patternTexture': { value: null },
'edgeStrength': { value: 1.0 },
'edgeGlow': { value: 1.0 },
'usePatternTexture': { value: 0.0 }
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`varying vec2 vUv;
uniform sampler2D maskTexture;
uniform sampler2D edgeTexture1;
uniform sampler2D edgeTexture2;
uniform sampler2D patternTexture;
uniform float edgeStrength;
uniform float edgeGlow;
uniform bool usePatternTexture;
void main() {
vec4 edgeValue1 = texture2D(edgeTexture1, vUv);
vec4 edgeValue2 = texture2D(edgeTexture2, vUv);
vec4 maskColor = texture2D(maskTexture, vUv);
vec4 patternColor = texture2D(patternTexture, 6.0 * vUv);
float visibilityFactor = 1.0 - maskColor.g > 0.0 ? 1.0 : 0.5;
vec4 edgeValue = edgeValue1 + edgeValue2 * edgeGlow;
vec4 finalColor = edgeStrength * maskColor.r * edgeValue;
if(usePatternTexture)
finalColor += + visibilityFactor * (1.0 - maskColor.r) * (1.0 - patternColor.r);
gl_FragColor = finalColor;
}`,
blending: AdditiveBlending,
depthTest: false,
depthWrite: false,
transparent: true
} );
}
}
OutlinePass.BlurDirectionX = new Vector2( 1.0, 0.0 );
OutlinePass.BlurDirectionY = new Vector2( 0.0, 1.0 );
export { OutlinePass };

View File

@@ -5,14 +5,19 @@
export { GLTFLoader } from "./examples/jsm/loaders/GLTFLoader";
export { DRACOLoader } from "./examples/jsm/loaders/DRACOLoader";
export * as SkeletonUtils from "./examples/jsm/utils/SkeletonUtils";
export { TransformControls } from "./examples/jsm/controls/TransformControls";
export { EffectComposer } from "./examples/jsm/postprocessing/EffectComposer";
export { RenderPass } from "./examples/jsm/postprocessing/RenderPass";
export { OutlinePass } from "./examples/jsm/postprocessing/OutlinePass";
export { ShaderPass } from "./examples/jsm/postprocessing/ShaderPass";
export { SMAAPass } from "./examples/jsm/postprocessing/SMAAPass";
export { OutputPass } from "./examples/jsm/postprocessing/OutputPass";
export { UnrealBloomPass } from "./examples/jsm/postprocessing/UnrealBloomPass";
export { BrightnessContrastShader } from "./examples/jsm/shaders/BrightnessContrastShader";
export { HueSaturationShader } from "./examples/jsm/shaders/HueSaturationShader";
export { ExposureShader } from "./examples/jsm/shaders/ExposureShader";

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14" height="14" version="1.1" viewBox="0 0 14 14" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7 0-6 3.5v7l6 3.5 6-3.5v-7l-6-3.5z" fill="#2e388a"/><g transform="translate(-.0405 .026)" fill="none" stroke="#53a6da" stroke-width=".7"><path d="m8.294 9.112c-1.291 0.7556-2.601 1.165-3.664 1.229-1.085 0.0656-1.803-0.2277-2.095-0.7272-0.2924-0.4996-0.1966-1.269 0.3921-2.183 0.5768-0.8956 1.575-1.837 2.866-2.593 1.291-0.7556 2.601-1.165 3.664-1.229 1.085-0.06552 1.803 0.2277 2.095 0.7272s0.1964 1.269-0.3862 2.174-1.581 1.846-2.872 2.602z"/><path d="m5.787 9.112c1.291 0.7556 2.601 1.165 3.664 1.229 1.085 0.0656 1.803-0.2277 2.095-0.7272 0.2924-0.4996 0.1966-1.269-0.3921-2.183-0.5768-0.8956-1.575-1.837-2.866-2.593-1.291-0.7556-2.601-1.165-3.664-1.229-1.085-0.06552-1.803 0.2277-2.095 0.7272s-0.1964 1.269 0.3862 2.174 1.581 1.846 2.872 2.602z"/><path d="m7.038 1.75c-0.5788 0-1.194 0.4711-1.686 1.441-0.4817 0.9502-0.7898 2.287-0.7898 3.783s0.3081 2.833 0.7898 3.783c0.4916 0.9697 1.107 1.441 1.686 1.441s1.194-0.4712 1.686-1.441c0.4817-0.9502 0.7898-2.287 0.7898-3.783s-0.3081-2.833-0.7898-3.783c-0.4916-0.9698-1.107-1.441-1.686-1.441z"/></g><path d="m7 8c0.5524 0 1-0.4478 1-1 0-0.5524-0.4478-1-1-1s-1 0.4478-1 1c0 0.5524 0.4478 1 1 1z" fill="#fff" stroke-width=".6439"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="14" height="14" version="1.1" viewBox="0 0 14 14" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7 0-6 3.5v7l6 3.5 6-3.5v-7l-6-3.5z" fill="#2e388a"/><g fill="#27aae1" stroke-width=".7"><path d="m9.1 3.801v1.398h-0.6992-2.801-2.1v1.4 1.4h1.4v-1.4h0.6992v2.801h-2.1v1.4h2.1 1.4v-1.4h1.4 0.6992v1.4h1.4v-1.4-1.4h-2.1v-1.4h2.1v-1.4-1.398h-1.4z"/><path d="m7 5.2c0.7732 0 1.4-0.6268 1.4-1.4s-0.6268-1.4-1.4-1.4-1.4 0.6268-1.4 1.4 0.6268 1.4 1.4 1.4z"/></g></svg>

After

Width:  |  Height:  |  Size: 528 B

View File

@@ -54,7 +54,11 @@ export const AssetCard = ({
<div id={id} style={{ ...styles.cardContainer, width: size, height: size }}>
<div style={{ ...styles.previewContainer, width: size, height: size }}>
<CheckeredBackground />
<AssetPreviewImage assetShortHeader={assetShortHeader} maxSize={128} />
<AssetPreviewImage
assetShortHeader={assetShortHeader}
maxSize={size}
loading="lazy"
/>
</div>
<div
style={{

View File

@@ -28,9 +28,14 @@ const styles = {
type Props = {|
assetShortHeader: AssetShortHeader,
maxSize?: number,
loading?: 'lazy',
|};
export const AssetPreviewImage = ({ assetShortHeader, maxSize }: Props) => {
export const AssetPreviewImage = ({
assetShortHeader,
maxSize,
loading,
}: Props) => {
const previewImageUrl = assetShortHeader.previewImageUrls[0];
const isPrivate = isPrivateAsset(assetShortHeader);
const [isLoaded, setIsLoaded] = React.useState(false);
@@ -55,6 +60,7 @@ export const AssetPreviewImage = ({ assetShortHeader, maxSize }: Props) => {
url={previewImageUrl}
alt={assetShortHeader.name}
onLoad={onLoad}
loading={loading}
/>
) : (
<CorsAwareImage
@@ -63,6 +69,7 @@ export const AssetPreviewImage = ({ assetShortHeader, maxSize }: Props) => {
src={previewImageUrl}
alt={assetShortHeader.name}
onLoad={onLoad}
loading={loading}
/>
);
};

View File

@@ -53,7 +53,7 @@ import HelpIcon from '../UI/HelpIcon';
import { OwnedProductLicense } from './ProductLicense/ProductLicenseOptions';
import { getUserProductPurchaseUsageType } from './ProductPageHelper';
const ASSETS_DISPLAY_LIMIT = 250;
const ASSETS_DISPLAY_LIMIT = 60;
const getAssetSize = (windowSize: WindowSizeType) => {
switch (windowSize) {
@@ -338,7 +338,7 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
scrollViewElement.scrollToPosition(y);
},
setPageBreakIndex: (index: number) => {
setPageBreakIndex(0);
setPageBreakIndex(index);
},
}));
@@ -471,6 +471,8 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
if (!assetShortHeaders) return null;
// Don't show assets if filtering on asset packs.)
if (hasAssetPackFiltersApplied && !openedAssetPack) return [];
const assetSize = getAssetSize(windowSize);
const margin = cellSpacing / 2;
return getAssetShortHeadersToDisplay(
assetShortHeaders,
@@ -480,9 +482,9 @@ const AssetsList = React.forwardRef<Props, AssetsListInterface>(
<AssetCardTile
assetShortHeader={assetShortHeader}
onOpenDetails={() => onOpenDetails(assetShortHeader)}
size={getAssetSize(windowSize)}
size={assetSize}
key={assetShortHeader.id}
margin={cellSpacing / 2}
margin={margin}
hideShortDescription={!!hideDetails}
/>
));

View File

@@ -11,6 +11,7 @@ type Props = {|
onError?: () => void,
onLoad?: (e: any) => void,
hideLoader?: boolean,
loading?: 'lazy',
|};
const AuthorizedAssetImage = ({
@@ -20,6 +21,7 @@ const AuthorizedAssetImage = ({
onError,
onLoad,
hideLoader,
loading,
}: Props) => {
const [authorizedUrl, setAuthorizedUrl] = React.useState(null);
const [isImageLoaded, setIsImageLoaded] = React.useState(false);
@@ -78,6 +80,7 @@ const AuthorizedAssetImage = ({
<img
alt={alt}
src={authorizedUrl}
loading={loading}
style={{
...style,
// Use display none to load the image in the background, but not

View File

@@ -244,6 +244,7 @@ export const PublicAssetPackTile = ({
style={styles.previewImage}
src={assetPack.thumbnailUrl}
alt={`Preview image of asset pack ${assetPack.name}`}
loading="lazy"
/>
<Column>
<Line justifyContent="space-between" noMargin>
@@ -299,6 +300,7 @@ export const PrivateAssetPackTile = ({
style={styles.previewImage}
src={assetPackListingData.thumbnailUrls[0]}
alt={`Preview image of asset pack ${assetPackListingData.name}`}
loading="lazy"
/>
{assetPackListingData.redeemConditions && !owned && (
<div style={styles.redeemableContainer}>
@@ -367,6 +369,7 @@ export const PromoBundleCard = ({
}}
src={productListingData.thumbnailUrls[0]}
alt={`Preview image of bundle ${productListingData.name}`}
loading="lazy"
/>
</div>
<Column expand alignItems="flex-start" justifyContent="center">
@@ -476,6 +479,7 @@ export const CategoryTile = ({
}}
src={imageSource}
alt={imageAlt}
// No lazy loading because categories are the first seen tiles in the shop.
/>
<Column>
<Line justifyContent="center" noMargin>
@@ -527,6 +531,7 @@ export const PrivateGameTemplateTile = ({
alt={`Preview image of game template ${
privateGameTemplateListingData.name
}`}
loading="lazy"
/>
<div style={styles.priceTagContainer}>
<ProductPriceTag
@@ -603,6 +608,7 @@ export const ExampleTile = ({
style={styles.previewImage}
src={thumbnailImgUrl}
alt={`Preview image of example ${exampleShortHeader.name}`}
loading="lazy"
/>
) : (
<EmptyMessage

View File

@@ -645,11 +645,11 @@ export const AssetStore = React.forwardRef<Props, AssetStoreInterface>(
<Column expand useFullHeight noMargin>
<SearchBar
placeholder={
hideGameTemplates ? t`Search assets` : `Search the shop`
hideGameTemplates ? t`Search assets` : t`Search the shop`
}
value={searchText}
onChange={(newValue: string) => {
if (searchText === newValue) {
if (searchText === newValue || newValue.length === 1) {
return;
}
setSearchText(newValue);

View File

@@ -1,6 +1,7 @@
// @flow
import BehaviorPropertiesEditor from './Editors/BehaviorPropertiesEditor';
import Physics2Editor from './Editors/Physics2Editor';
import Physics3DEditor from './Editors/Physics3DEditor';
/**
* A service returning editor components for each behavior type.
@@ -16,6 +17,9 @@ const BehaviorsEditorService = {
'Physics2::Physics2Behavior': {
component: Physics2Editor,
},
'Physics3D::Physics3DBehavior': {
component: Physics3DEditor,
},
},
};

View File

@@ -0,0 +1,402 @@
// @flow
import { t } from '@lingui/macro';
import { Trans } from '@lingui/macro';
import * as React from 'react';
import { Line, Column, Spacer } from '../../../UI/Grid';
import Checkbox from '../../../UI/Checkbox';
import SelectField from '../../../UI/SelectField';
import SelectOption from '../../../UI/SelectOption';
import SemiControlledTextField from '../../../UI/SemiControlledTextField';
import { getMeasurementUnitShortLabel } from '../../../PropertiesEditor/PropertiesMapToSchema';
import MeasurementUnitDocumentation from '../../../PropertiesEditor/MeasurementUnitDocumentation';
import { type BehaviorEditorProps } from '../BehaviorEditorProps.flow';
import Text from '../../../UI/Text';
import DismissableAlertMessage from '../../../UI/DismissableAlertMessage';
import { ResponsiveLineStackLayout } from '../../../UI/Layout';
import useForceUpdate from '../../../Utils/UseForceUpdate';
import Button from '@material-ui/core/Button';
import ButtonGroup from '@material-ui/core/ButtonGroup';
import InputAdornment from '@material-ui/core/InputAdornment';
import Tooltip from '@material-ui/core/Tooltip';
type Props = BehaviorEditorProps;
const NumericProperty = (props: {|
id?: string,
properties: gdMapStringPropertyDescriptor,
propertyName: string,
step: number,
onUpdate: (newValue: string) => void,
|}) => {
const { properties, propertyName, step, onUpdate, id } = props;
const property = properties.get(propertyName);
return (
<SemiControlledTextField
id={id}
fullWidth
value={property.getValue()}
key={propertyName}
floatingLabelText={property.getLabel()}
step={step}
onChange={onUpdate}
type="number"
endAdornment={<UnitAdornment property={property} />}
/>
);
};
const UnitAdornment = (props: {| property: gdPropertyDescriptor |}) => {
const { property } = props;
const measurementUnit = property.getMeasurementUnit();
return (
<Tooltip
title={
<MeasurementUnitDocumentation
label={measurementUnit.getLabel()}
description={measurementUnit.getDescription()}
elementsWithWords={measurementUnit.getElementsWithWords()}
/>
}
>
<InputAdornment position="end">
{getMeasurementUnitShortLabel(measurementUnit)}
</InputAdornment>
</Tooltip>
);
};
const BitGroupEditor = (props: {|
bits: Array<boolean>,
onChange: (index: number, value: boolean) => void,
firstIndex: number,
disabled: boolean,
|}) => {
return (
<div style={{ overflowX: 'auto', flex: 1 }}>
<ButtonGroup disableElevation fullWidth disabled={props.disabled}>
{props.bits.map((bit, index) => (
<Button
key={props.firstIndex + index}
variant={bit ? 'contained' : 'outlined'}
color={bit ? 'primary' : 'default'}
onClick={() => props.onChange(props.firstIndex + index, !bit)}
>
{props.firstIndex + index + 1}
</Button>
))}
</ButtonGroup>
</div>
);
};
const isBitEnabled = (bitsValue: number, pos: number) => {
return !!(bitsValue & (1 << pos));
};
const enableBit = (bitsValue: number, pos: number, enable: boolean) => {
if (enable) bitsValue |= 1 << pos;
else bitsValue &= ~(1 << pos);
return bitsValue;
};
const Physics3DEditor = (props: Props) => {
const { behavior, onBehaviorUpdated } = props;
const forceUpdate = useForceUpdate();
const updateBehaviorProperty = React.useCallback(
(property, value) => {
behavior.updateProperty(property, value);
forceUpdate();
onBehaviorUpdated();
},
[behavior, forceUpdate, onBehaviorUpdated]
);
const properties = behavior.getProperties();
const staticBits = Array(4).fill(null);
const dynamicBits = Array(4).fill(null);
const shape = properties.get('shape').getValue();
const layersValues = parseInt(properties.get('layers').getValue(), 10);
const masksValues = parseInt(properties.get('masks').getValue(), 10);
const isStatic = properties.get('bodyType').getValue() === 'Static';
return (
<Column
expand
// Avoid overflow on small screens
noOverflowParent
>
<Line>
<SelectField
id="physics3d-parameter-body-type"
key={'bodyType'}
fullWidth
floatingLabelText={properties.get('bodyType').getLabel()}
value={properties.get('bodyType').getValue()}
onChange={(e, i, newValue: string) =>
updateBehaviorProperty('bodyType', newValue)
}
>
{[
<SelectOption
key={'dynamic'}
value={'Dynamic'}
label={t`Dynamic`}
/>,
<SelectOption key={'static'} value={'Static'} label={t`Static`} />,
<SelectOption
key={'kinematic'}
value={'Kinematic'}
label={t`Kinematic`}
/>,
]}
</SelectField>
</Line>
<ResponsiveLineStackLayout>
<Checkbox
label={properties.get('bullet').getLabel()}
checked={properties.get('bullet').getValue() === 'true'}
onCheck={(e, checked) =>
updateBehaviorProperty('bullet', checked ? '1' : '0')
}
/>
<Checkbox
label={properties.get('fixedRotation').getLabel()}
checked={properties.get('fixedRotation').getValue() === 'true'}
onCheck={(e, checked) =>
updateBehaviorProperty('fixedRotation', checked ? '1' : '0')
}
/>
</ResponsiveLineStackLayout>
<Line>
<DismissableAlertMessage
identifier="physics2-shape-collisions"
kind="info"
>
<Trans>
The shape used in the Physics behavior is independent from the
collision mask of the object. Be sure to use the "Collision"
condition provided by the Physics behavior in the events. The usual
"Collision" condition won't take into account the shape that you've
set up here.
</Trans>
</DismissableAlertMessage>
</Line>
<ResponsiveLineStackLayout>
<SelectField
id="physics3d-parameter-shape"
fullWidth
floatingLabelText={properties.get('shape').getLabel()}
value={properties.get('shape').getValue()}
onChange={(e, i, newValue: string) =>
updateBehaviorProperty('shape', newValue)
}
>
<SelectOption key={'sphere'} value={'Sphere'} label={t`Sphere`} />
<SelectOption key={'box'} value={'Box'} label={t`Box`} />
<SelectOption key={'capsule'} value={'Capsule'} label={t`Capsule`} />
<SelectOption
key={'cylinder'}
value={'Cylinder'}
label={t`Cylinder`}
/>
</SelectField>
<SelectField
id="physics3d-parameter-shape-orientation"
fullWidth
floatingLabelText={properties.get('shapeOrientation').getLabel()}
value={properties.get('shapeOrientation').getValue()}
onChange={(e, i, newValue: string) =>
updateBehaviorProperty('shapeOrientation', newValue)
}
disabled={
properties.get('shape').getValue() === 'Sphere' ||
properties.get('shape').getValue() === 'Box'
}
>
<SelectOption key={'shape-orientation-z'} value={'Z'} label={t`Z`} />
<SelectOption key={'shape-orientation-y'} value={'Y'} label={t`Y`} />
<SelectOption key={'shape-orientation-x'} value={'X'} label={t`X`} />
</SelectField>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout>
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionA').getValue()}
key={'shapeDimensionA'}
floatingLabelText={shape === 'Box' ? 'Width' : 'Radius'}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionA', newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get('shapeDimensionA')} />
}
/>
{shape !== 'Sphere' && (
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionB').getValue()}
key={'shapeDimensionB'}
floatingLabelText={shape === 'Box' ? 'Width' : 'Depth'}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionB', newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get('shapeDimensionB')} />
}
/>
)}
{shape === 'Box' && (
<SemiControlledTextField
fullWidth
value={properties.get('shapeDimensionC').getValue()}
key={'shapeDimensionC'}
floatingLabelText={'Depth'}
min={0}
onChange={newValue =>
updateBehaviorProperty('shapeDimensionC', newValue)
}
type="number"
endAdornment={
<UnitAdornment property={properties.get('shapeDimensionC')} />
}
/>
)}
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout>
<NumericProperty
id="physics3d-parameter-density"
properties={properties}
propertyName={'density'}
step={0.1}
onUpdate={newValue =>
updateBehaviorProperty(
'density',
parseFloat(newValue) > 0 ? newValue : '0'
)
}
/>
<NumericProperty
properties={properties}
propertyName={'gravityScale'}
step={0.1}
onUpdate={newValue =>
updateBehaviorProperty('gravityScale', newValue)
}
/>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout>
<NumericProperty
properties={properties}
propertyName={'friction'}
step={0.1}
onUpdate={newValue =>
updateBehaviorProperty(
'friction',
parseFloat(newValue) > 0 ? newValue : '0'
)
}
/>
<NumericProperty
properties={properties}
propertyName={'restitution'}
step={0.1}
onUpdate={newValue =>
updateBehaviorProperty(
'restitution',
parseFloat(newValue) > 0 ? newValue : '0'
)
}
/>
</ResponsiveLineStackLayout>
<ResponsiveLineStackLayout>
<NumericProperty
properties={properties}
propertyName={'linearDamping'}
step={0.05}
onUpdate={newValue =>
updateBehaviorProperty('linearDamping', newValue)
}
/>
<NumericProperty
id="physics3d-parameter-angular-damping"
properties={properties}
propertyName={'angularDamping'}
step={0.05}
onUpdate={newValue =>
updateBehaviorProperty('angularDamping', newValue)
}
/>
</ResponsiveLineStackLayout>
<Line>
<Text style={{ marginRight: 10 }}>
{properties.get('layers').getLabel()}
</Text>
<BitGroupEditor
key={'static-layers'}
firstIndex={0}
bits={staticBits.map(
(_, index) => isBitEnabled(layersValues, index) && isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(layersValues, index, value);
updateBehaviorProperty('layers', newValue.toString(10));
}}
disabled={!isStatic}
/>
<Spacer />
<BitGroupEditor
key={'dynamic-layers'}
firstIndex={4}
bits={dynamicBits.map(
(_, index) => isBitEnabled(layersValues, index + 4) && !isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(layersValues, index, value);
updateBehaviorProperty('layers', newValue.toString(10));
}}
disabled={isStatic}
/>
</Line>
<Line>
<Text style={{ marginRight: 10 }}>
{properties.get('masks').getLabel()}
</Text>
<BitGroupEditor
key={'static-mask'}
firstIndex={0}
bits={staticBits.map(
(_, index) => isBitEnabled(masksValues, index) || isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(masksValues, index, value);
updateBehaviorProperty('masks', newValue.toString(10));
}}
disabled={isStatic}
/>
<Spacer />
<BitGroupEditor
key={'dynamic-mask'}
firstIndex={4}
bits={dynamicBits.map(
(_, index) => isBitEnabled(masksValues, index + 4) || isStatic
)}
onChange={(index, value) => {
const newValue = enableBit(masksValues, index, value);
updateBehaviorProperty('masks', newValue.toString(10));
}}
disabled={isStatic}
/>
</Line>
</Column>
);
};
export default Physics3DEditor;

View File

@@ -51,18 +51,19 @@ export default class CommandManager implements CommandManagerInterface {
}
registerCommand = (commandName: CommandName, command: Command) => {
if (this._commands[commandName])
return console.warn(
`Tried to register command ${commandName}, but it is already registered.`
);
if (this._commands[commandName]) return;
// if (this._commands[commandName])
// return console.warn(
// `Tried to register command ${commandName}, but it is already registered.`
// );
this._commands[commandName] = command;
};
deregisterCommand = (commandName: CommandName) => {
if (!this._commands[commandName])
return console.warn(
`Tried to deregister command ${commandName}, but it is not registered.`
);
// if (!this._commands[commandName])
// return console.warn(
// `Tried to deregister command ${commandName}, but it is not registered.`
// );
delete this._commands[commandName];
};

View File

@@ -1,38 +1,62 @@
// @flow
import { t } from '@lingui/macro';
import * as React from 'react';
import { I18n } from '@lingui/react';
import SelectField from '../UI/SelectField';
import SelectOption from '../UI/SelectOption';
import { type DebuggerId } from '../ExportAndShare/PreviewLauncher.flow';
import {
type DebuggerId,
type DebuggerStatus,
} from '../ExportAndShare/PreviewLauncher.flow';
type Props = {|
selectedId: DebuggerId,
debuggerIds: Array<DebuggerId>,
debuggerStatus: { [DebuggerId]: DebuggerStatus },
onChooseDebugger: DebuggerId => void,
|};
export default class DebuggerSelector extends React.Component<Props, void> {
render() {
const hasDebuggers = !!this.props.debuggerIds.length;
const debuggerIds = Object.keys(this.props.debuggerStatus);
const hasDebuggers = !!debuggerIds.length;
return (
<SelectField
fullWidth
value={hasDebuggers ? this.props.selectedId : 0}
onChange={(e, i, value) =>
this.props.onChooseDebugger(parseInt(value, 10) || 0)
}
disabled={!hasDebuggers}
>
{this.props.debuggerIds.map(id => (
<SelectOption value={id} key={id} label={t`Game preview #${id}`} />
))}
{!hasDebuggers && (
<SelectOption
value={0}
label={t`No preview running. Run a preview and you will be able to inspect it with the debugger`}
/>
<I18n>
{({ i18n }) => (
<SelectField
fullWidth
value={hasDebuggers ? this.props.selectedId : 0}
onChange={(e, i, value) =>
this.props.onChooseDebugger(parseInt(value, 10) || 0)
}
disabled={!hasDebuggers}
>
{debuggerIds.map(id => {
const status = this.props.debuggerStatus[+id];
const statusText = status.isPaused
? status.isInGameEdition
? t`Editing`
: t`Paused`
: status.isInGameEdition
? t`Playing (in-game edition)`
: t`Playing`;
return (
<SelectOption
value={+id}
key={id}
label={t`Game preview #${id} (${i18n._(statusText)})`}
/>
);
})}
{!hasDebuggers && (
<SelectOption
value={0}
label={t`No preview running. Run a preview and you will be able to inspect it with the debugger`}
/>
)}
</SelectField>
)}
</SelectField>
</I18n>
);
}
}

View File

@@ -14,6 +14,7 @@ import EmptyMessage from '../UI/EmptyMessage';
import {
type PreviewDebuggerServer,
type DebuggerId,
type DebuggerStatus,
} from '../ExportAndShare/PreviewLauncher.flow';
import { type Log, LogsManager } from './DebuggerConsole';
@@ -53,7 +54,7 @@ type State = {|
debuggerGameData: { [DebuggerId]: any },
profilerOutputs: { [DebuggerId]: ProfilerOutput },
profilingInProgress: { [DebuggerId]: boolean },
gameIsPaused: { [DebuggerId]: boolean },
debuggerStatus: { [DebuggerId]: DebuggerStatus },
selectedId: DebuggerId,
logs: { [DebuggerId]: Array<Log> },
|};
@@ -70,7 +71,7 @@ export default class Debugger extends React.Component<Props, State> {
debuggerGameData: {},
profilerOutputs: {},
profilingInProgress: {},
gameIsPaused: {},
debuggerStatus: {},
selectedId: 0,
logs: {},
};
@@ -79,18 +80,22 @@ export default class Debugger extends React.Component<Props, State> {
_debuggerLogs: Map<number, LogsManager> = new Map();
updateToolbar = () => {
const { selectedId, gameIsPaused } = this.state;
const { selectedId, debuggerStatus } = this.state;
const selectedDebuggerContents = this._debuggerContents[
this.state.selectedId
];
const isSelectedDebuggerPaused = debuggerStatus[selectedId]
? debuggerStatus[selectedId].isPaused
: false;
this.props.setToolbar(
<Toolbar
onPlay={() => this._play(this.state.selectedId)}
onPause={() => this._pause(this.state.selectedId)}
canPlay={this._hasSelectedDebugger() && gameIsPaused[selectedId]}
canPause={this._hasSelectedDebugger() && !gameIsPaused[selectedId]}
canPlay={this._hasSelectedDebugger() && isSelectedDebuggerPaused}
canPause={this._hasSelectedDebugger() && !isSelectedDebuggerPaused}
canOpenProfiler={this._hasSelectedDebugger()}
isProfilerShown={
!!selectedDebuggerContents &&
@@ -161,14 +166,14 @@ export default class Debugger extends React.Component<Props, State> {
debuggerGameData,
profilerOutputs,
profilingInProgress,
gameIsPaused,
debuggerStatus,
}) => {
// Remove any data bound to the instance that might have been stored.
// Otherwise this would be a memory leak.
if (debuggerGameData[id]) delete debuggerGameData[id];
if (profilerOutputs[id]) delete profilerOutputs[id];
if (profilingInProgress[id]) delete profilingInProgress[id];
if (gameIsPaused[id]) delete gameIsPaused[id];
if (debuggerStatus[id]) delete debuggerStatus[id];
return {
debuggerIds,
@@ -181,7 +186,7 @@ export default class Debugger extends React.Component<Props, State> {
debuggerGameData,
profilerOutputs,
profilingInProgress,
gameIsPaused,
debuggerStatus,
};
},
() => this.updateToolbar()
@@ -219,6 +224,11 @@ export default class Debugger extends React.Component<Props, State> {
this.setState({
unregisterDebuggerServerCallbacks: unregisterCallbacks,
});
// Fetch the status of each debugger client.
previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => {
previewDebuggerServer.sendMessage(debuggerId, { command: 'getStatus' });
});
};
_handleMessage = (id: DebuggerId, data: any) => {
@@ -229,6 +239,16 @@ export default class Debugger extends React.Component<Props, State> {
[id]: data.payload,
},
});
} else if (data.command === 'status') {
this.setState(
state => ({
debuggerStatus: {
...state.debuggerStatus,
[id]: data.payload,
},
}),
() => this.updateToolbar()
);
} else if (data.command === 'profiler.output') {
this.setState({
profilerOutputs: {
@@ -244,22 +264,10 @@ export default class Debugger extends React.Component<Props, State> {
this.setState(state => ({
profilingInProgress: { ...state.profilingInProgress, [id]: false },
}));
} else if (data.command === 'game.resumed') {
this.setState(
state => ({
gameIsPaused: { ...state.gameIsPaused, [id]: false },
}),
() => this.updateToolbar()
);
} else if (data.command === 'game.paused') {
this.setState(
state => ({
gameIsPaused: { ...state.gameIsPaused, [id]: true },
}),
() => this.updateToolbar()
);
} else if (data.command === 'hotReloader.logs') {
// Nothing to do.
} else if (data.command === 'instances.updated') {
// Nothing to do.
} else if (data.command === 'console.log') {
// Filter out unavoidable warnings that do not concern non-engine devs.
if (isUnavoidableLibraryWarning(data.payload)) return;
@@ -276,24 +284,14 @@ export default class Debugger extends React.Component<Props, State> {
const { previewDebuggerServer } = this.props;
previewDebuggerServer.sendMessage(id, { command: 'play' });
this.setState(
state => ({
gameIsPaused: { ...state.gameIsPaused, [id]: false },
}),
() => this.updateToolbar()
);
// Pause status is transmitted by the game (using `status`).
};
_pause = (id: DebuggerId) => {
const { previewDebuggerServer } = this.props;
previewDebuggerServer.sendMessage(id, { command: 'pause' });
this.setState(
state => ({
gameIsPaused: { ...state.gameIsPaused, [id]: true },
}),
() => this.updateToolbar()
);
// Pause status is transmitted by the game (using `status`).
};
_refresh = (id: DebuggerId) => {
@@ -345,7 +343,7 @@ export default class Debugger extends React.Component<Props, State> {
debuggerServerError,
debuggerServerState,
selectedId,
debuggerIds,
debuggerStatus,
debuggerGameData,
profilerOutputs,
profilingInProgress,
@@ -375,7 +373,7 @@ export default class Debugger extends React.Component<Props, State> {
<Column expand noMargin>
<DebuggerSelector
selectedId={selectedId}
debuggerIds={debuggerIds}
debuggerStatus={debuggerStatus}
onChooseDebugger={id =>
this.setState(
{

View File

@@ -0,0 +1,115 @@
// @flow
import * as React from 'react';
import { type PreviewDebuggerServer } from '../ExportAndShare/PreviewLauncher.flow';
type AttachToPreviewOptions = {|
previewIndexHtmlLocation: string,
|};
type SwitchToSceneEditionOptions = {|
sceneName: string,
externalLayoutName?: string,
|};
let onAttachToPreview: null | (AttachToPreviewOptions => void) = null;
let onSwitchToSceneEdition: null | (SwitchToSceneEditionOptions => void) = null;
export const attachToPreview = ({
previewIndexHtmlLocation,
}: AttachToPreviewOptions) => {
if (!onAttachToPreview) throw new Error('No EmbeddedGameFrame registered.');
onAttachToPreview({ previewIndexHtmlLocation });
};
export const switchToSceneEdition = ({
sceneName,
externalLayoutName,
}: SwitchToSceneEditionOptions) => {
if (!onSwitchToSceneEdition)
throw new Error('No EmbeddedGameFrame registered.');
onSwitchToSceneEdition({ sceneName, externalLayoutName });
};
type Props = {|
previewDebuggerServer: PreviewDebuggerServer | null,
onLaunchPreviewForInGameEdition: ({|
sceneName: string,
externalLayoutName: ?string,
|}) => void,
|};
export const EmbeddedGameFrame = ({
previewDebuggerServer,
onLaunchPreviewForInGameEdition,
}: Props) => {
const [
previewIndexHtmlLocation,
setPreviewIndexHtmlLocation,
] = React.useState<string>('');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
React.useEffect(
() => {
// TODO: use a real context for this?
onAttachToPreview = (options: AttachToPreviewOptions) => {
setPreviewIndexHtmlLocation(options.previewIndexHtmlLocation);
if (iframeRef.current) {
iframeRef.current.contentWindow.focus();
}
};
onSwitchToSceneEdition = (options: SwitchToSceneEditionOptions) => {
if (!previewDebuggerServer) return;
const { sceneName, externalLayoutName } = options;
if (!previewIndexHtmlLocation) {
console.info(
externalLayoutName
? `Launching in-game edition preview for external layout "${externalLayoutName}" (scene: "${sceneName}").`
: `Launching in-game edition preview for scene "${sceneName}".`
);
onLaunchPreviewForInGameEdition({ sceneName, externalLayoutName });
} else {
console.info(
externalLayoutName
? `Switching in-game edition previews to external layout "${externalLayoutName}" (scene: "${sceneName}").`
: `Switching in-game edition previews to scene "${sceneName}".`
);
previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => {
previewDebuggerServer.sendMessage(debuggerId, {
command: 'switchForInGameEdition',
sceneName,
externalLayoutName,
});
});
}
};
},
[
previewDebuggerServer,
previewIndexHtmlLocation,
onLaunchPreviewForInGameEdition,
]
);
return (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<iframe
ref={iframeRef}
title="Game Preview"
src={previewIndexHtmlLocation}
tabIndex={-1}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
border: 'none',
}}
/>
</div>
</div>
);
};

View File

@@ -129,6 +129,7 @@ type Props = {|
oldName: string,
newName: string
) => void,
children?: React.Node,
|};
export const EventsFunctionParametersEditor = ({
@@ -146,6 +147,7 @@ export const EventsFunctionParametersEditor = ({
onMoveBehaviorEventsParameter,
onMoveObjectEventsParameter,
onFunctionParameterWillBeRenamed,
children,
}: Props) => {
const scrollView = React.useRef<?ScrollViewInterface>(null);
const [
@@ -177,6 +179,22 @@ export const EventsFunctionParametersEditor = ({
const forceUpdate = useForceUpdate();
const isABehaviorLifecycleEventsFunction =
!!eventsBasedBehavior &&
gd.MetadataDeclarationHelper.isBehaviorLifecycleEventsFunction(
eventsFunction.getName()
);
const isAnObjectLifecycleEventsFunction =
!!eventsBasedObject &&
!eventsBasedBehavior &&
gd.MetadataDeclarationHelper.isObjectLifecycleEventsFunction(
eventsFunction.getName()
);
freezeParameters =
freezeParameters ||
isABehaviorLifecycleEventsFunction ||
isAnObjectLifecycleEventsFunction;
const [
longDescriptionShownIndexes,
setLongDescriptionShownIndexes,
@@ -558,22 +576,6 @@ export const EventsFunctionParametersEditor = ({
? ParametersIndexOffsets.ObjectFunction
: ParametersIndexOffsets.FreeFunction;
const isABehaviorLifecycleEventsFunction =
!!eventsBasedBehavior &&
gd.MetadataDeclarationHelper.isBehaviorLifecycleEventsFunction(
eventsFunction.getName()
);
if (isABehaviorLifecycleEventsFunction) {
return (
<EmptyMessage>
<Trans>
This is a "lifecycle method". It will be called automatically by the
game engine and has two parameters: "Object" (the object the behavior
is acting on) and "Behavior" (the behavior itself).
</Trans>
</EmptyMessage>
);
}
const isAnExtensionLifecycleEventsFunction =
!eventsBasedBehavior &&
gd.MetadataDeclarationHelper.isExtensionLifecycleEventsFunction(
@@ -616,6 +618,18 @@ export const EventsFunctionParametersEditor = ({
{parameters.getParametersCount() > 0 || freezeParameters ? (
<React.Fragment>
<ScrollView ref={scrollView}>
{children
? React.Children.map(children, (child, index) => {
if (!child) return null;
return (
<Line>
<Column noMargin expand>
{child}
</Column>
</Line>
);
})
: null}
<Line>
<Column noMargin expand>
{mapFor(0, parameters.getParametersCount(), i => {
@@ -878,25 +892,45 @@ export const EventsFunctionParametersEditor = ({
)}
</React.Fragment>
) : (
<Column noMargin expand justifyContent="center">
<EmptyPlaceholder
title={<Trans>Add your first parameter</Trans>}
description={
<Trans>Parameters allow function users to give data.</Trans>
}
actionLabel={<Trans>Add a parameter</Trans>}
helpPagePath={helpPagePath}
helpPageAnchor={'add-and-use-parameters'}
onAction={addParameter}
secondaryActionIcon={<PasteIcon />}
secondaryActionLabel={
isClipboardContainingParameters ? <Trans>Paste</Trans> : null
}
onSecondaryAction={() => {
pasteParametersAtTheEnd();
}}
/>
</Column>
<ScrollView>
{children
? React.Children.map(children, (child, index) => {
if (!child) return null;
return (
<Line>
<Column noMargin expand>
{child}
</Column>
</Line>
);
})
: null}
<Line>
<Column noMargin expand justifyContent="center">
<EmptyPlaceholder
title={<Trans>Add your first parameter</Trans>}
description={
<Trans>
Parameters allow function users to give data.
</Trans>
}
actionLabel={<Trans>Add a parameter</Trans>}
helpPagePath={helpPagePath}
helpPageAnchor={'add-and-use-parameters'}
onAction={addParameter}
secondaryActionIcon={<PasteIcon />}
secondaryActionLabel={
isClipboardContainingParameters ? (
<Trans>Paste</Trans>
) : null
}
onSecondaryAction={() => {
pasteParametersAtTheEnd();
}}
/>
</Column>
</Line>
</ScrollView>
)}
</Column>
)}

View File

@@ -6,7 +6,6 @@ import ObjectGroupsListWithObjectGroupEditor from '../../ObjectGroupsList/Object
import { Tabs } from '../../UI/Tabs';
import { EventsFunctionParametersEditor } from './EventsFunctionParametersEditor';
import { EventsFunctionPropertiesEditor } from './EventsFunctionPropertiesEditor';
import ScrollView from '../../UI/ScrollView';
import { Column, Line } from '../../UI/Grid';
import { type GroupWithContext } from '../../ObjectsList/EnumerateObjects';
import { type UnsavedChanges } from '../../MainFrame/UnsavedChangesContext';
@@ -163,50 +162,27 @@ export default class EventsFunctionConfigurationEditor extends React.Component<
return (
<Column expand useFullHeight noOverflowParent>
<Line>
<Column noMargin expand noOverflowParent>
<Tabs
value={this.state.currentTab}
onChange={this._chooseTab}
options={[
{
value: ('config': TabNames),
label: <Trans>Configuration</Trans>,
},
{
value: ('parameters': TabNames),
label: <Trans>Parameters</Trans>,
},
hasLegacyFunctionObjectGroups
? {
value: ('groups': TabNames),
label: <Trans>Object groups</Trans>,
}
: null,
].filter(Boolean)}
/>
</Column>
</Line>
{this.state.currentTab === 'config' ? (
<ScrollView>
<Line>
<EventsFunctionPropertiesEditor
project={project}
eventsFunction={eventsFunction}
eventsBasedBehavior={eventsBasedBehavior}
eventsBasedObject={eventsBasedObject}
eventsFunctionsContainer={eventsFunctionsContainer}
eventsFunctionsExtension={eventsFunctionsExtension}
helpPagePath={helpPagePath}
onConfigurationUpdated={onConfigurationUpdated}
renderConfigurationHeader={renderConfigurationHeader}
freezeEventsFunctionType={freezeEventsFunctionType}
getFunctionGroupNames={getFunctionGroupNames}
{hasLegacyFunctionObjectGroups ? (
<Line>
<Column noMargin expand noOverflowParent>
<Tabs
value={this.state.currentTab}
onChange={this._chooseTab}
options={[
{
value: ('config': TabNames),
label: <Trans>Configuration</Trans>,
},
{
value: ('groups': TabNames),
label: <Trans>Object groups</Trans>,
},
]}
/>
</Line>
</ScrollView>
</Column>
</Line>
) : null}
{this.state.currentTab === 'parameters' ? (
{this.state.currentTab === 'config' ? (
<EventsFunctionParametersEditor
project={project}
projectScopedContainersAccessor={projectScopedContainersAccessor}
@@ -223,7 +199,21 @@ export default class EventsFunctionConfigurationEditor extends React.Component<
onMoveObjectEventsParameter={onMoveObjectEventsParameter}
onFunctionParameterWillBeRenamed={onFunctionParameterWillBeRenamed}
key={eventsFunction ? eventsFunction.ptr : null}
/>
>
<EventsFunctionPropertiesEditor
project={project}
eventsFunction={eventsFunction}
eventsBasedBehavior={eventsBasedBehavior}
eventsBasedObject={eventsBasedObject}
eventsFunctionsContainer={eventsFunctionsContainer}
eventsFunctionsExtension={eventsFunctionsExtension}
helpPagePath={helpPagePath}
onConfigurationUpdated={onConfigurationUpdated}
renderConfigurationHeader={renderConfigurationHeader}
freezeEventsFunctionType={freezeEventsFunctionType}
getFunctionGroupNames={getFunctionGroupNames}
/>
</EventsFunctionParametersEditor>
) : null}
{this.state.currentTab === 'groups' ? (
<ObjectGroupsListWithObjectGroupEditor

View File

@@ -20,6 +20,7 @@ import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternal
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
import { isNativeMobileApp } from '../../../Utils/Platform';
import { getIDEVersionWithHash } from '../../../Version';
import { attachToPreview } from '../../../EmbeddedGame/EmbeddedGameFrame';
const gd: libGDevelop = global.gd;
type State = {|
@@ -158,6 +159,9 @@ export default class BrowserS3PreviewLauncher extends React.Component<
);
previewExportOptions.setLayoutName(layout.getName());
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
previewExportOptions.setIsInGameEdition(
previewOptions.isForInGameEdition
);
if (externalLayout) {
previewExportOptions.setExternalLayoutName(externalLayout.getName());
}
@@ -220,6 +224,12 @@ export default class BrowserS3PreviewLauncher extends React.Component<
// Upload any file that must be exported for the preview.
await browserS3FileSystem.uploadPendingObjects();
if (previewOptions.isForInGameEdition) {
attachToPreview({
previewIndexHtmlLocation: outputDir + '/index.html',
});
}
// Change the HTML file displayed by the preview window so that it starts loading
// the game.
previewWindows.forEach(

View File

@@ -4,7 +4,6 @@ import { Trans } from '@lingui/macro';
import * as React from 'react';
import LocalFileSystem from '../LocalFileSystem';
import optionalRequire from '../../../Utils/OptionalRequire';
import { timeFunction } from '../../../Utils/TimeFunction';
import { findGDJS } from '../../../GameEngineFinder/LocalGDJSFinder';
import LocalNetworkPreviewDialog from './LocalNetworkPreviewDialog';
import assignIn from 'lodash/assignIn';
@@ -22,6 +21,7 @@ import {
} from './LocalPreviewDebuggerServer';
import Window from '../../../Utils/Window';
import { getIDEVersionWithHash } from '../../../Version';
import { attachToPreview } from '../../../EmbeddedGame/EmbeddedGameFrame';
const electron = optionalRequire('electron');
const path = optionalRequire('path');
const ipcRenderer = electron ? electron.ipcRenderer : null;
@@ -47,6 +47,28 @@ type State = {|
captureOptions: ?CaptureOptions,
|};
const prepareExporter = async (): Promise<{|
outputDir: string,
exporter: gdjsExporter,
gdjsRoot: string,
|}> => {
const { gdjsRoot } = await findGDJS();
console.info('GDJS found in ', gdjsRoot);
const localFileSystem = new LocalFileSystem({
downloadUrlsToLocalFiles: false,
});
const fileSystem = assignIn(new gd.AbstractFileSystemJS(), localFileSystem);
const outputDir = path.join(fileSystem.getTempDir(), 'preview');
const exporter = new gd.Exporter(fileSystem, gdjsRoot);
return {
outputDir,
exporter,
gdjsRoot,
};
};
export default class LocalPreviewLauncher extends React.Component<
PreviewLauncherProps,
State
@@ -171,168 +193,138 @@ export default class LocalPreviewLauncher extends React.Component<
);
};
_prepareExporter = (): Promise<{|
outputDir: string,
exporter: gdjsExporter,
gdjsRoot: string,
|}> => {
return findGDJS().then(({ gdjsRoot }) => {
console.info('GDJS found in ', gdjsRoot);
const localFileSystem = new LocalFileSystem({
downloadUrlsToLocalFiles: false,
});
const fileSystem = assignIn(
new gd.AbstractFileSystemJS(),
localFileSystem
);
const outputDir = path.join(fileSystem.getTempDir(), 'preview');
const exporter = new gd.Exporter(fileSystem, gdjsRoot);
return {
outputDir,
exporter,
gdjsRoot,
};
});
};
launchPreview = (previewOptions: PreviewOptions): Promise<any> => {
launchPreview = async (previewOptions: PreviewOptions): Promise<any> => {
const { project, layout, externalLayout } = previewOptions;
// Start the debugger server for previews. Even if not used,
// useful if the user opens the Debugger editor later, or want to
// hot reload.
return this.getPreviewDebuggerServer()
.startServer()
.catch(err => {
// Ignore any error when running the debugger server - the preview
// can still work without it.
console.error(
'Unable to start the Debugger Server for the preview:',
err
);
})
.then(() => this._prepareExporter())
.then(({ outputDir, exporter, gdjsRoot }) => {
timeFunction(
() => {
const previewExportOptions = new gd.PreviewExportOptions(
project,
outputDir
);
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
previewExportOptions.setLayoutName(layout.getName());
if (externalLayout) {
previewExportOptions.setExternalLayoutName(
externalLayout.getName()
);
}
try {
await this.getPreviewDebuggerServer().startServer();
} catch (err) {
console.error(
'Unable to start the Debugger Server for the preview:',
err
);
}
const previewDebuggerServerAddress = getDebuggerServerAddress();
if (previewDebuggerServerAddress) {
previewExportOptions.useWebsocketDebuggerClientWithServerAddress(
previewDebuggerServerAddress.address,
'' + previewDebuggerServerAddress.port
);
}
const { outputDir, exporter, gdjsRoot } = await prepareExporter();
const includeFileHashs = this.props.getIncludeFileHashs();
for (const includeFile in includeFileHashs) {
const hash = includeFileHashs[includeFile];
previewExportOptions.setIncludeFileHash(includeFile, hash);
}
var previewStartTime = performance.now();
// Give the preview the path to the "@electron/remote" module of the editor,
// as this is required by some features and we've not removed dependency
// on "@electron/remote" yet.
previewExportOptions.setElectronRemoteRequirePath(
path.join(
gdjsRoot,
'../preview_node_modules',
'@electron/remote',
'renderer/index.js'
)
);
const previewExportOptions = new gd.PreviewExportOptions(
project,
outputDir
);
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
previewExportOptions.setLayoutName(layout.getName());
previewExportOptions.setIsInGameEdition(previewOptions.isForInGameEdition);
if (externalLayout) {
previewExportOptions.setExternalLayoutName(externalLayout.getName());
}
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
const shouldHotReload =
previewOptions.hotReload && !!debuggerIds.length;
const previewDebuggerServerAddress = getDebuggerServerAddress();
if (previewDebuggerServerAddress) {
previewExportOptions.useWebsocketDebuggerClientWithServerAddress(
previewDebuggerServerAddress.address,
'' + previewDebuggerServerAddress.port
);
}
previewExportOptions.setProjectDataOnlyExport(
// Only export project data if asked and if a hot-reloading is being done.
shouldHotReload && previewOptions.projectDataOnlyExport
);
const includeFileHashs = this.props.getIncludeFileHashs();
for (const includeFile in includeFileHashs) {
const hash = includeFileHashs[includeFile];
previewExportOptions.setIncludeFileHash(includeFile, hash);
}
previewExportOptions.setFullLoadingScreen(
previewOptions.fullLoadingScreen
);
previewExportOptions.setGDevelopVersionWithHash(
getIDEVersionWithHash()
);
previewExportOptions.setCrashReportUploadLevel(
this.props.crashReportUploadLevel
);
previewExportOptions.setPreviewContext(this.props.previewContext);
previewExportOptions.setProjectTemplateSlug(
project.getTemplateSlug()
);
previewExportOptions.setSourceGameId(this.props.sourceGameId);
// Give the preview the path to the "@electron/remote" module of the editor,
// as this is required by some features and we've not removed dependency
// on "@electron/remote" yet.
previewExportOptions.setElectronRemoteRequirePath(
path.join(
gdjsRoot,
'../preview_node_modules',
'@electron/remote',
'renderer/index.js'
)
);
if (previewOptions.fallbackAuthor) {
previewExportOptions.setFallbackAuthor(
previewOptions.fallbackAuthor.id,
previewOptions.fallbackAuthor.username
);
}
if (previewOptions.authenticatedPlayer) {
previewExportOptions.setAuthenticatedPlayer(
previewOptions.authenticatedPlayer.playerId,
previewOptions.authenticatedPlayer.playerUsername,
previewOptions.authenticatedPlayer.playerToken
);
}
if (previewOptions.captureOptions) {
if (previewOptions.captureOptions.screenshots) {
previewOptions.captureOptions.screenshots.forEach(
screenshot => {
previewExportOptions.addScreenshotCapture(
screenshot.delayTimeInSeconds,
screenshot.signedUrl,
screenshot.publicUrl
);
}
);
}
}
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length;
exporter.exportProjectForPixiPreview(previewExportOptions);
previewExportOptions.delete();
exporter.delete();
previewExportOptions.setProjectDataOnlyExport(
// Only export project data if asked and if a hot-reloading is being done.
shouldHotReload && previewOptions.projectDataOnlyExport
);
if (shouldHotReload) {
debuggerIds.forEach(debuggerId => {
this.getPreviewDebuggerServer().sendMessage(debuggerId, {
command: 'hotReload',
});
});
previewExportOptions.setFullLoadingScreen(previewOptions.fullLoadingScreen);
previewExportOptions.setGDevelopVersionWithHash(getIDEVersionWithHash());
previewExportOptions.setCrashReportUploadLevel(
this.props.crashReportUploadLevel
);
previewExportOptions.setPreviewContext(this.props.previewContext);
previewExportOptions.setProjectTemplateSlug(project.getTemplateSlug());
previewExportOptions.setSourceGameId(this.props.sourceGameId);
if (
this.state.hotReloadsCount % 16 === 0 &&
this._hotReloadSubscriptionChecker
) {
this._hotReloadSubscriptionChecker.checkUserHasSubscription();
}
this.setState(state => ({
hotReloadsCount: state.hotReloadsCount + 1,
}));
} else {
this._openPreviewWindow(project, outputDir, previewOptions);
}
},
time => console.info(`Preview took ${time}ms`)
);
if (previewOptions.fallbackAuthor) {
previewExportOptions.setFallbackAuthor(
previewOptions.fallbackAuthor.id,
previewOptions.fallbackAuthor.username
);
}
if (previewOptions.authenticatedPlayer) {
previewExportOptions.setAuthenticatedPlayer(
previewOptions.authenticatedPlayer.playerId,
previewOptions.authenticatedPlayer.playerUsername,
previewOptions.authenticatedPlayer.playerToken
);
}
if (previewOptions.captureOptions) {
if (previewOptions.captureOptions.screenshots) {
previewOptions.captureOptions.screenshots.forEach(screenshot => {
previewExportOptions.addScreenshotCapture(
screenshot.delayTimeInSeconds,
screenshot.signedUrl,
screenshot.publicUrl
);
});
}
}
exporter.exportProjectForPixiPreview(previewExportOptions);
previewExportOptions.delete();
exporter.delete();
if (shouldHotReload) {
debuggerIds.forEach(debuggerId => {
this.getPreviewDebuggerServer().sendMessage(debuggerId, {
command: 'hotReload',
});
});
if (
this.state.hotReloadsCount % 16 === 0 &&
this._hotReloadSubscriptionChecker
) {
this._hotReloadSubscriptionChecker.checkUserHasSubscription();
}
this.setState(state => ({
hotReloadsCount: state.hotReloadsCount + 1,
}));
} else {
if (previewOptions.isForInGameEdition) {
attachToPreview({
previewIndexHtmlLocation: `file://${outputDir}/index.html`,
});
}
if (previewOptions.numberOfWindows >= 1) {
this._openPreviewWindow(project, outputDir, previewOptions);
}
}
const previewStopTime = performance.now();
console.info(`Preview took ${previewStopTime - previewStartTime}ms`);
};
getPreviewDebuggerServer() {

View File

@@ -15,6 +15,10 @@ export type LaunchPreviewOptions = {
fullLoadingScreen?: boolean,
forceDiagnosticReport?: boolean,
numberOfWindows?: number,
isForInGameEdition?: {|
forcedSceneName: string,
forcedExternalLayoutName: ?string,
|},
launchCaptureOptions?: LaunchCaptureOptions,
};
export type CaptureOptions = {|
@@ -40,6 +44,7 @@ export type PreviewOptions = {|
playerToken: string,
},
numberOfWindows: number,
isForInGameEdition: boolean,
getIsMenuBarHiddenInPreview: () => boolean,
getIsAlwaysOnTopInPreview: () => boolean,
captureOptions: CaptureOptions,
@@ -59,6 +64,13 @@ export type PreviewLauncherProps = {|
/** Each game connected to the debugger server is identified by a unique number. */
export type DebuggerId = number;
/** Each game connected to the debugger server can communicate its status. */
export type DebuggerStatus = {|
isPaused: boolean,
isInGameEdition: boolean,
sceneName: string | null,
|};
/** The callbacks for a debugger server used for previews. */
export type PreviewDebuggerServerCallbacks = {|
onErrorReceived: (err: Error) => void | Promise<void>,

View File

@@ -38,6 +38,8 @@ import { type MenuItemTemplate } from '../../UI/Menu/Menu.flow';
import { showErrorBox } from '../../UI/Messages/MessageBox';
import CircularProgress from '../../UI/CircularProgress';
import FlatButton from '../../UI/FlatButton';
import ChevronArrowRight from '../../UI/CustomSvgIcons/ChevronArrowRight';
import ChevronArrowLeft from '../../UI/CustomSvgIcons/ChevronArrowLeft';
const styles = {
// Make select field width not dependent on build names (name is truncated).
@@ -67,27 +69,31 @@ const pushOrCreateKey = (
}
return object;
};
const pageSize = 20;
const groupFeedbacks = (
i18n: I18nType,
feedbacks: Array<Comment>,
currentPage: number,
{ build, date }: {| build: boolean, date: boolean |}
): { [buildIdOrDate: string]: Array<Comment> } => {
const feedbacksByBuild = feedbacks.reduce((acc, feedback) => {
if (build) {
if (!feedback.buildId) {
return pushOrCreateKey('game-only', feedback, acc);
const feedbacksByBuild = feedbacks
.slice(pageSize * (currentPage - 1), pageSize * currentPage)
.reduce((acc, feedback) => {
if (build) {
if (!feedback.buildId) {
return pushOrCreateKey('game-only', feedback, acc);
} else {
return pushOrCreateKey(feedback.buildId, feedback, acc);
}
} else {
return pushOrCreateKey(feedback.buildId, feedback, acc);
const dateKey = i18n.date(feedback.createdAt, {
month: 'long',
year: 'numeric',
});
return pushOrCreateKey(dateKey, feedback, acc);
}
} else {
const dateKey = i18n.date(feedback.createdAt, {
month: 'long',
year: 'numeric',
});
return pushOrCreateKey(dateKey, feedback, acc);
}
}, {});
}, {});
return feedbacksByBuild;
};
@@ -96,8 +102,9 @@ const getDisplayedFeedbacks = (
feedbacks: ?Array<Comment>,
showUnprocessed: boolean,
sortByDate: boolean,
filter: string
): ?{ [buildIdOrDate: string]: Array<Comment> } => {
filter: string,
currentPage: number
): ?Array<Comment> => {
if (!feedbacks) return null;
let filteredFeedbacksByBuild = feedbacks;
if (filter === 'game-only') {
@@ -112,15 +119,7 @@ const getDisplayedFeedbacks = (
? filterUnprocessedComments(filteredFeedbacksByBuild)
: filteredFeedbacksByBuild;
return sortByDate
? groupFeedbacks(i18n, filteredFeedbacksByBuildAndUnprocessed, {
build: false,
date: true,
})
: groupFeedbacks(i18n, filteredFeedbacksByBuildAndUnprocessed, {
build: true,
date: false,
});
return filteredFeedbacksByBuildAndUnprocessed;
};
const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
@@ -137,21 +136,57 @@ const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
const [isMarkingAllAsProcessed, setIsMarkingAllAsProcessed] = React.useState(
false
);
const [currentPage, setCurrentPage] = React.useState<number>(1);
const displayedFeedbacks = getDisplayedFeedbacks(
i18n,
feedbacks,
showProcessed,
sortByDate,
filter
filter,
currentPage
);
const displayedFeedbacksArray: Comment[] = displayedFeedbacks
? // $FlowFixMe - Flow doesn't understand that we're flattening the array.
Object.values(displayedFeedbacks)
.filter(Boolean)
.flat()
: [];
const isShowingCommentsForASpecificBuild =
filter !== '' && filter !== 'game-only';
const displayedFeedbacksByDateOrBuild: ?{
[buildIdOrDate: string]: Array<Comment>,
} = !displayedFeedbacks
? null
: groupFeedbacks(
i18n,
displayedFeedbacks,
currentPage,
sortByDate
? {
build: false,
date: true,
}
: {
build: true,
date: false,
}
);
const totalNumberOfPages = Math.ceil(
!displayedFeedbacks ? 1 : displayedFeedbacks.length / pageSize
);
const onCurrentPageChange = React.useCallback(
newPage => {
const minPage = 1;
const maxPage = totalNumberOfPages;
if (newPage < minPage) {
setCurrentPage(minPage);
} else if (newPage > maxPage) {
setCurrentPage(maxPage);
} else {
setCurrentPage(newPage);
}
},
[setCurrentPage, totalNumberOfPages]
);
const getBuildNameOption = (buildId: string) => {
const shortenedUuid = shortenUuidForDisplay(buildId);
@@ -252,11 +287,11 @@ const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
};
const markAllCommentsAsProcessed = async (i18n: I18nType) => {
if (!profile || isMarkingAllAsProcessed) return;
if (!profile || isMarkingAllAsProcessed || !displayedFeedbacks) return;
try {
setIsMarkingAllAsProcessed(true);
await Promise.all(
displayedFeedbacksArray
displayedFeedbacks
.filter(comment => !comment.processedAt)
.map(comment =>
updateComment(getAuthorizationHeader, profile.id, {
@@ -303,6 +338,13 @@ const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
},
];
React.useEffect(
() => {
setCurrentPage(1);
},
[sortByDate, showProcessed, filter]
);
return (
<>
<Column noMargin expand>
@@ -324,7 +366,10 @@ const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
)}
{authenticatedUser.authenticated && displayedFeedbacks && (
<Column expand noMargin>
<ResponsiveLineStackLayout justifyContent="space-between">
<ResponsiveLineStackLayout
justifyContent="space-between"
noColumnMargin
>
<Column justifyContent="center">
<LineStackLayout noMargin alignItems="center">
<BackgroundText>
@@ -350,7 +395,7 @@ const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
<SelectOption
key={'all'}
value={''}
label={t`All builds`}
label={t`All exports`}
/>
<SelectOption
key={'game-only'}
@@ -374,89 +419,154 @@ const GameFeedback = ({ i18n, authenticatedUser, game }: Props) => {
</ResponsiveLineStackLayout>
<ColumnStackLayout expand noMargin>
{!!feedbacks && feedbacks.length > 0 && (
// TODO: Should it display the data for the filtered pieces of feedback only?
<FeedbackAverageCard feedbacks={feedbacks} />
)}
</ColumnStackLayout>
{displayedFeedbacksArray.length === 0 && (
{displayedFeedbacks.length === 0 && (
<>
{showProcessed ? (
<LineStackLayout
alignItems="center"
justifyContent="center"
>
<Text>
<Trans>
You don't have any unread feedback for this game.
</Trans>
</Text>
<EmptyMessage>
{isShowingCommentsForASpecificBuild ? (
<Trans>
You don't have any unread feedback for this export.
</Trans>
) : (
<Trans>
You don't have any unread feedback for this game.
</Trans>
)}
</EmptyMessage>
<FlatButton
onClick={() => setShowProcessed(false)}
label={<Trans>Show all feedbacks</Trans>}
/>
</LineStackLayout>
) : (
<Text>
<Trans>You don't have any feedback for this game.</Trans>
</Text>
<EmptyMessage>
{isShowingCommentsForASpecificBuild ? (
<Trans>
You don't have any feedback for this export.
</Trans>
) : (
<Trans>
You don't have any feedback for this game.
</Trans>
)}
</EmptyMessage>
)}
</>
)}
{displayedFeedbacksArray.length !== 0 && (
<ColumnStackLayout expand noMargin>
{Object.keys(displayedFeedbacks).map((key, index) => {
const title = sortByDate ? key : getBuildNameTitle(key);
return (
<ColumnStackLayout key={key} noMargin>
<Line
justifyContent="space-between"
alignItems="center"
>
<Column>
<Text size="block-title">
{title}
{title ? ' - ' : ' '}
{displayedFeedbacks[key].length === 1 ? (
<Trans>1 review</Trans>
) : (
<Trans>
{displayedFeedbacks[key].length} reviews
</Trans>
{displayedFeedbacks.length !== 0 &&
!!displayedFeedbacksByDateOrBuild && (
<ColumnStackLayout expand noMargin>
{Object.keys(displayedFeedbacksByDateOrBuild).map(
(key, index) => {
const title = sortByDate ? key : getBuildNameTitle(key);
return (
<ColumnStackLayout key={key} noMargin>
<Line
justifyContent="space-between"
alignItems="center"
>
<Column>
<Text size="block-title">
{title}
{title ? ' - ' : ' '}
{displayedFeedbacksByDateOrBuild[key]
.length === 1 ? (
<Trans>1 review</Trans>
) : (
<Trans>
{
displayedFeedbacksByDateOrBuild[key]
.length
}{' '}
reviews
</Trans>
)}
</Text>
</Column>
{index === 0 && (
<Line alignItems="center">
<Line
noMargin
expand
alignItems="center"
justifyContent="flex-end"
>
<IconButton
tooltip={t`Previous page`}
onClick={() =>
onCurrentPageChange(currentPage - 1)
}
disabled={currentPage === 1}
size="small"
>
<ChevronArrowLeft />
</IconButton>
<Text
noMargin
style={{
opacity: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
{totalNumberOfPages === 1
? 1
: `${currentPage}/${totalNumberOfPages}`}
</Text>
<IconButton
tooltip={t`Next page`}
onClick={() =>
onCurrentPageChange(currentPage + 1)
}
disabled={
currentPage >= totalNumberOfPages
}
size="small"
>
<ChevronArrowRight />
</IconButton>
</Line>
<IconButton
disabled={isMarkingAllAsProcessed}
onClick={event =>
openOptionsContextMenu(event)
}
>
{!isMarkingAllAsProcessed ? (
<Options fontSize="small" />
) : (
<CircularProgress size={20} />
)}
</IconButton>
</Line>
)}
</Text>
</Column>
{index === 0 && (
<Column justifyContent="center">
<IconButton
disabled={isMarkingAllAsProcessed}
onClick={event => openOptionsContextMenu(event)}
>
{!isMarkingAllAsProcessed ? (
<Options fontSize="small" />
) : (
<CircularProgress size={20} />
)}
</IconButton>
</Column>
)}
</Line>
{displayedFeedbacks[key].map(
(comment: Comment, index: number) => (
<FeedbackCard
key={comment.id}
comment={comment}
buildProperties={getBuildPropertiesForComment(
comment
)}
authenticatedUser={authenticatedUser}
onCommentUpdated={onCommentUpdated}
/>
)
)}
</ColumnStackLayout>
);
})}
</ColumnStackLayout>
)}
</Line>
{displayedFeedbacksByDateOrBuild[key].map(
(comment: Comment, index: number) => (
<FeedbackCard
key={comment.id}
comment={comment}
buildProperties={getBuildPropertiesForComment(
comment
)}
authenticatedUser={authenticatedUser}
onCommentUpdated={onCommentUpdated}
/>
)
)}
</ColumnStackLayout>
);
}
)}
</ColumnStackLayout>
)}
</Column>
)}
</Line>

View File

@@ -1,8 +1,9 @@
// @flow
import * as React from 'react';
import { t, Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import * as React from 'react';
import { type I18n as I18nType } from '@lingui/core';
import Tooltip from '@material-ui/core/Tooltip';
import {
ColumnStackLayout,
LineStackLayout,
@@ -50,13 +51,37 @@ import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
import { textEllipsisStyle } from '../UI/TextEllipsis';
import FileWithLines from '../UI/CustomSvgIcons/FileWithLines';
import TextButton from '../UI/TextButton';
import { Tooltip } from '@material-ui/core';
import { getRelativeOrAbsoluteDisplayDate } from '../Utils/DateDisplay';
const electron = optionalRequire('electron');
const path = optionalRequire('path');
export const getThumbnailWidth = ({ isMobile }: {| isMobile: boolean |}) =>
isMobile ? undefined : Math.min(245, Math.max(130, window.innerWidth / 4));
export const getProjectDisplayDate = (i18n: I18nType, date: number) =>
getRelativeOrAbsoluteDisplayDate({
i18n,
dateAsNumber: date,
sameDayFormat: 'todayAndHour',
dayBeforeFormat: 'yesterdayAndHour',
relativeLimit: 'currentWeek',
sameWeekFormat: 'thisWeek',
});
export const getDetailedProjectDisplayDate = (i18n: I18nType, date: number) =>
i18n.date(date, {
dateStyle: 'short',
timeStyle: 'short',
});
const getNoProjectAlertMessage = () => {
if (!electron) {
// Trying to open a local project from the web app of the mobile app.
return t`Looks like your project isn't there!${'\n\n'}Your project must be stored on your computer.`;
} else {
return t`We couldn't find your project.${'\n\n'}If your project is stored on a different computer, launch GDevelop on that computer.${'\n'}Otherwise, use the "Open project" button and find it in your filesystem.`;
}
};
const styles = {
tooltipButtonContainer: {
display: 'flex',
@@ -67,7 +92,7 @@ const styles = {
display: 'flex',
flexShrink: 0,
flexDirection: 'column',
justifyContent: 'flex-end',
justifyContent: 'center',
},
iconAndText: { display: 'flex', gap: 2, alignItems: 'flex-start' },
title: {
@@ -281,18 +306,10 @@ const GameDashboardCard = ({
<Text size="block-title" noMargin style={styles.title}>
{gameName || <Trans>Unknown game</Trans>}
</Text>
{projectsList.length > 0 && game && (
{projectsList.length >= 2 && game && (
<>
<Spacer />
<Tooltip
title={
projectsList.length === 1 ? (
<Trans>{projectsList.length} project</Trans>
) : (
<Trans>{projectsList.length} projects</Trans>
)
}
>
<Tooltip title={<Trans>{projectsList.length} projects</Trans>}>
{/* Button must be wrapped in a container so that the parent tooltip
can display even if the button is disabled. */}
<div style={styles.tooltipButtonContainer}>
@@ -325,16 +342,29 @@ const GameDashboardCard = ({
authenticatedUser={authenticatedUser}
currentFileMetadata={currentFileMetadata}
textColor="secondary"
textSize="body-small"
textPrefix={isCurrentProjectOpened ? null : <Trans>Last edited:</Trans>}
/>
) : game ? (
<LineStackLayout noMargin expand>
<Text color="secondary" noMargin size="body-small">
<Trans>Last edited:</Trans>
</Text>
<Text color="secondary" noMargin size="body-small">
{i18n.date((game.updatedAt || 0) * 1000)}
{!itemStorageProvider && isCurrentProjectOpened ? (
<Trans>Draft created:</Trans>
) : (
<Trans>Last edited:</Trans>
)}
</Text>
<Tooltip
placement="right"
title={getDetailedProjectDisplayDate(
i18n,
(game.updatedAt || 0) * 1000
)}
>
<Text color="secondary" noMargin size="body-small">
{getProjectDisplayDate(i18n, (game.updatedAt || 0) * 1000)}
</Text>
</Tooltip>
</LineStackLayout>
) : null;
@@ -387,18 +417,16 @@ const GameDashboardCard = ({
const actions = [];
if (projectsList.length > 1) {
actions.push(
...[
...projectsList.map(fileMetadataAndStorageProviderName => {
return {
label: getProjectItemLabel(
fileMetadataAndStorageProviderName,
storageProviders,
i18n
),
click: () => onOpenProject(fileMetadataAndStorageProviderName),
};
}),
]
...projectsList.slice(0, 3).map(fileMetadataAndStorageProviderName => {
return {
label: getProjectItemLabel(
fileMetadataAndStorageProviderName,
storageProviders,
i18n
),
click: () => onOpenProject(fileMetadataAndStorageProviderName),
};
})
);
if (game) {
@@ -457,7 +485,10 @@ const GameDashboardCard = ({
// If there are multiple projects, suggest opening the game dashboard.
actions.push({
label: i18n._(t`See all projects`),
click: game ? () => onOpenGameManager({ game }) : undefined,
click: game
? () =>
onOpenGameManager({ game, widgetToScrollTo: 'projects' })
: undefined,
});
}
@@ -571,7 +602,7 @@ const GameDashboardCard = ({
: () => {
showAlert({
title: t`No project to open`,
message: t`Looks like your project isn't there!${'\n\n'}You may be using a different computer or opening GDevelop on the web and your project is saved locally.`,
message: getNoProjectAlertMessage(),
});
};

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import { I18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro';
import { type I18n as I18nType } from '@lingui/core';
import Tooltip from '@material-ui/core/Tooltip';
import { getGameMainImageUrl, type Game } from '../Utils/GDevelopServices/Game';
import { GameThumbnail } from './GameThumbnail';
import { useResponsiveWindowSize } from '../UI/Responsive/ResponsiveWindowMeasurer';
@@ -23,9 +24,10 @@ import RaisedButton from '../UI/RaisedButton';
import Edit from '../UI/CustomSvgIcons/Edit';
import GameLinkAndShareIcons from './GameLinkAndShareIcons';
import { CompactToggleField } from '../UI/CompactToggleField';
import { FixedHeightFlexContainer } from '../UI/Grid';
import { FixedHeightFlexContainer, Line } from '../UI/Grid';
import useOnResize from '../Utils/UseOnResize';
import useForceUpdate from '../Utils/UseForceUpdate';
import { getDetailedProjectDisplayDate } from './GameDashboardCard';
const styles = {
iconAndText: { display: 'flex', gap: 2, alignItems: 'flex-start' },
@@ -106,9 +108,21 @@ const GameHeader = ({
};
const renderTitle = (i18n: I18nType) => (
<ColumnStackLayout noMargin>
<Text color="secondary" noMargin>
<Trans>Created on {i18n.date(game.createdAt * 1000)}</Trans>
</Text>
<Line noMargin>
<Tooltip
placement="right"
title={getDetailedProjectDisplayDate(i18n, game.createdAt * 1000)}
>
<Text color="secondary" noMargin>
<Trans>
Created on
{i18n.date(game.createdAt * 1000, {
dateStyle: 'short',
})}
</Trans>
</Text>
</Tooltip>
</Line>
<Text size="block-title" noMargin>
{game.gameName}
</Text>

View File

@@ -232,14 +232,14 @@ export const GameRegistration = ({
</Text>
<Text noMargin>
<Trans>
Get ready-made packs to make your game visible to the GDevelop
community.
Make your game visible to the GDevelop community and to the
world with Marketing Boosts.
</Trans>
</Text>
<Line>
<RightLoader isLoading={!game}>
<RaisedButton
label={<Trans>See marketing packs</Trans>}
label={<Trans>See Marketing Boosts</Trans>}
primary
onClick={() => setMarketingPlansDialogOpen(true)}
disabled={!game}

View File

@@ -395,17 +395,20 @@ const GamesList = ({
searchText ? 250 : 150
);
// Refresh games to display, depending on a few parameters.
React.useEffect(getDashboardItemsToDisplayDebounced, [
React.useEffect(
getDashboardItemsToDisplayDebounced,
searchText, // search text changes (user input)
games, // games change (when updating a game for instance)
currentPage, // user changes page
orderBy, // user changes order
currentFileMetadata, // opened project changes (when opening or closing a project from here)
allRecentProjectFiles.length, // list of recent projects changes (when a project is removed from list)
project, // opened project changes (when closing a project from here)
]);
// Refresh games to display, depending on a few parameters.
[
getDashboardItemsToDisplayDebounced,
searchText, // search text changes (user input)
games, // games change (when updating a game for instance)
currentPage, // user changes page
orderBy, // user changes order
currentFileMetadata, // opened project changes (when opening or closing a project from here)
allRecentProjectFiles.length, // list of recent projects changes (when a project is removed from list)
project, // opened project changes (when closing a project from here)
]
);
const projectUuid = project ? project.getProjectUuid() : null;
@@ -428,10 +431,11 @@ const GamesList = ({
lastModifiedInfoByProjectId,
setLastModifiedInfoByProjectId,
] = React.useState({});
// Look at projects where lastCommittedBy is not the current user (cloud projects only), and fetch
// public profiles of the users that have modified them.
React.useEffect(
() => {
// Look at projects where lastCommittedBy is not the current user (cloud projects only), and fetch
// public profiles of the users that have modified them.
const updateModificationInfoByProjectId = async () => {
if (!cloudProjects || !profile) return;
@@ -449,6 +453,15 @@ const GamesList = ({
[cloudProjects, profile]
);
React.useEffect(
() => {
// Reset pagination when modifying the sorting order.
setCurrentPage(1);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[orderBy]
);
const shouldShowOpenProject =
canOpen &&
// Only show on large screens.
@@ -616,8 +629,11 @@ const GamesList = ({
const isCurrentProjectOpened =
(!!projectUuid && (!!game && game.id === projectUuid)) ||
(!!projectFileMetadataAndStorageProviderName &&
!!currentFileMetadata &&
projectFileMetadataAndStorageProviderName.fileMetadata
.gameId === projectUuid);
.gameId === projectUuid &&
projectFileMetadataAndStorageProviderName.fileMetadata
.fileIdentifier === currentFileMetadata.fileIdentifier);
return (
<GameDashboardCard

View File

@@ -23,6 +23,7 @@ const useGamesList = (): GamesList => {
authenticated,
firebaseUser,
getAuthorizationHeader,
loginState,
} = authenticatedUser;
const [games, setGames] = React.useState<?Array<Game>>(null);
@@ -32,6 +33,8 @@ const useGamesList = (): GamesList => {
const fetchGames = React.useCallback(
async (): Promise<void> => {
if (loginState !== 'done') return;
if (!authenticated || !firebaseUser) {
setGames([]);
return;
@@ -53,7 +56,7 @@ const useGamesList = (): GamesList => {
gamesFetchingPromise.current = null;
}
},
[authenticated, firebaseUser, getAuthorizationHeader]
[authenticated, firebaseUser, getAuthorizationHeader, loginState]
);
const onGameUpdated = React.useCallback(

View File

@@ -5,11 +5,15 @@ import PreferencesContext from '../MainFrame/Preferences/PreferencesContext';
const electron = optionalRequire('electron');
const ipcRenderer = electron ? electron.ipcRenderer : null;
type Props = {
onGDJSUpdated: () => Promise<void> | void,
};
/**
* Set up some watchers for GDJS and Extensions sources.
* Stop the watchers when the component is unmounted or `shouldWatch` prop is false.
*/
export const LocalGDJSDevelopmentWatcher = () => {
export const LocalGDJSDevelopmentWatcher = ({ onGDJSUpdated }: Props) => {
const preferences = React.useContext(PreferencesContext);
const shouldWatch = preferences.values.useGDJSDevelopmentWatcher;
@@ -35,5 +39,35 @@ export const LocalGDJSDevelopmentWatcher = () => {
[shouldWatch]
);
React.useEffect(
() => {
if (!shouldWatch) {
// Nothing to set up in the effect if watch is deactivated.
return;
}
if (!ipcRenderer) {
return;
}
ipcRenderer.removeAllListeners(
'local-gdjs-development-watcher-runtime-updated'
);
ipcRenderer.on(
'local-gdjs-development-watcher-runtime-updated',
(event, err) => {
onGDJSUpdated();
}
);
return () => {
ipcRenderer.removeAllListeners(
'local-gdjs-development-watcher-runtime-updated'
);
};
},
[shouldWatch, onGDJSUpdated]
);
return null;
};

View File

@@ -53,6 +53,12 @@ const jsExtensions = [
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Physics2Behavior/JsExtension.js'),
objectsRenderingServiceModules: {},
},
{
name: 'Physics3D',
// $FlowExpectedError - this path is ignored for Flow.
extensionModule: require('GDJS-for-web-app-only/Runtime/Extensions/Physics3DBehavior/JsExtension.js'),
objectsRenderingServiceModules: {},
},
{
name: 'ExampleJsExtension',
// $FlowExpectedError - this path is ignored for Flow.

View File

@@ -97,7 +97,7 @@ export const create = (authentication: Authentication) => {
)}
quickPublishOnlineWebExporter={localOnlineWebExporter}
renderGDJSDevelopmentWatcher={
isDev ? () => <LocalGDJSDevelopmentWatcher /> : null
isDev ? ({ onGDJSUpdated }) => <LocalGDJSDevelopmentWatcher onGDJSUpdated={onGDJSUpdated} /> : null
}
storageProviders={storageProviders}
resourceMover={LocalResourceMover}

View File

@@ -27,6 +27,9 @@ import {
} from '../ResourcesWatcher';
import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope';
import { type ObjectWithContext } from '../../ObjectsList/EnumerateObjects';
import { switchToSceneEdition } from '../../EmbeddedGame/EmbeddedGameFrame';
const gameEditorMode = 'embedded-game'; // TODO: move to a preference.
const styles = {
container: {
@@ -69,6 +72,13 @@ export class ExternalLayoutEditorContainer extends React.Component<
layout ? layout.getName() : null,
projectItemName
);
if (gameEditorMode === 'embedded-game' && layout && projectItemName) {
switchToSceneEdition({
sceneName: layout.getName(),
externalLayoutName: projectItemName,
});
}
}
this.resourceExternallyChangedCallbackId = registerOnResourceExternallyChangedCallback(
this.onResourceExternallyChanged.bind(this)
@@ -88,6 +98,13 @@ export class ExternalLayoutEditorContainer extends React.Component<
layout ? layout.getName() : null,
projectItemName
);
if (gameEditorMode === 'embedded-game' && layout && projectItemName) {
switchToSceneEdition({
sceneName: layout.getName(),
externalLayoutName: projectItemName,
});
}
}
}

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { Trans } from '@lingui/macro';
import { I18n } from '@lingui/react';
import Tooltip from '@material-ui/core/Tooltip';
import Text from '../../../../UI/Text';
import { LineStackLayout } from '../../../../UI/Layout';
import {
@@ -9,11 +10,14 @@ import {
type StorageProvider,
} from '../../../../ProjectsStorage';
import { type AuthenticatedUser } from '../../../../Profile/AuthenticatedUserContext';
import { getRelativeOrAbsoluteDisplayDate } from '../../../../Utils/DateDisplay';
import { getGravatarUrl } from '../../../../UI/GravatarUrl';
import { type LastModifiedInfo } from './utils';
import { type FileMetadata } from '../../../../ProjectsStorage';
import AvatarWithStatusAndTooltip from './AvatarWithStatusAndTooltip';
import {
getDetailedProjectDisplayDate,
getProjectDisplayDate,
} from '../../../../GameDashboard/GameDashboardCard';
type LastModificationInfoProps = {|
file: FileMetadataAndStorageProviderName,
@@ -22,6 +26,7 @@ type LastModificationInfoProps = {|
authenticatedUser: AuthenticatedUser,
currentFileMetadata: ?FileMetadata,
textColor?: 'primary' | 'secondary',
textSize?: 'body-small',
textPrefix?: React.Node,
|};
@@ -32,6 +37,7 @@ const LastModificationInfo = ({
authenticatedUser,
currentFileMetadata,
textColor = 'primary',
textSize = 'body',
textPrefix,
}: LastModificationInfoProps) => {
const isProjectSavedOnCloud =
@@ -77,7 +83,7 @@ const LastModificationInfo = ({
{({ i18n }) => (
<LineStackLayout noMargin alignItems="center">
{textPrefix && (
<Text color="secondary" noMargin size="body-small">
<Text color="secondary" noMargin size={textSize}>
{textPrefix}
</Text>
)}
@@ -97,20 +103,20 @@ const LastModificationInfo = ({
hideStatus={!isProjectOpenedNotTheLatestVersion}
/>
)}
<Text noMargin color={textColor} size="body-small">
{isCurrentProjectOpened ? (
{isCurrentProjectOpened ? (
<Text noMargin color={textColor} size={textSize}>
<Trans>Modifying</Trans>
) : (
getRelativeOrAbsoluteDisplayDate({
i18n,
dateAsNumber: lastModifiedAt,
sameDayFormat: 'todayAndHour',
dayBeforeFormat: 'yesterdayAndHour',
relativeLimit: 'currentWeek',
sameWeekFormat: 'thisWeek',
})
)}
</Text>
</Text>
) : (
<Tooltip
placement="right"
title={getDetailedProjectDisplayDate(i18n, lastModifiedAt)}
>
<Text noMargin color={textColor} size={textSize}>
{getProjectDisplayDate(i18n, lastModifiedAt)}
</Text>
</Tooltip>
)}
</LineStackLayout>
)}
</I18n>

View File

@@ -90,6 +90,7 @@ const ProjectFileList = ({
}: Props) => {
const projectFiles = useProjectsListFor(game);
const contextMenu = React.useRef<?ContextMenuInterface>(null);
const [loadingProjectId, setLoadingProjectId] = React.useState<?string>(null);
const { removeRecentProjectFile } = React.useContext(PreferencesContext);
const authenticatedUser = React.useContext(AuthenticatedUserContext);
const {
@@ -141,6 +142,18 @@ const ProjectFileList = ({
[removeRecentProjectFile, showConfirmation]
);
const onWillDeleteCloudProject = React.useCallback(
async (i18n, file: FileMetadataAndStorageProviderName) => {
setLoadingProjectId(file.fileMetadata.fileIdentifier);
try {
await onDeleteCloudProject(i18n, file);
} finally {
setLoadingProjectId(null);
}
},
[onDeleteCloudProject]
);
const buildContextMenu = (
i18n: I18nType,
file: ?FileMetadataAndStorageProviderName
@@ -155,7 +168,7 @@ const ProjectFileList = ({
{ type: 'separator' },
{
label: i18n._(t`Delete`),
click: () => onDeleteCloudProject(i18n, file),
click: () => onWillDeleteCloudProject(i18n, file),
}
);
} else if (file.storageProviderName === 'LocalFile') {
@@ -226,7 +239,7 @@ const ProjectFileList = ({
</ListItem>
))
) : projectFiles.length > 0 ? (
<Line>
<Line noMargin>
<Column noMargin expand>
{!isMobile && (
<Line justifyContent="space-between">
@@ -253,7 +266,10 @@ const ProjectFileList = ({
key={file.fileMetadata.fileIdentifier}
file={file}
onOpenContextMenu={openContextMenu}
isLoading={disabled}
disabled={disabled}
isLoading={
file.fileMetadata.fileIdentifier === loadingProjectId
}
currentFileMetadata={currentFileMetadata}
storageProviders={storageProviders}
isWindowSizeMediumOrLarger={!isMobile}

View File

@@ -62,6 +62,7 @@ type ProjectFileListItemProps = {|
storageProviders: Array<StorageProvider>,
onOpenProject: (file: FileMetadataAndStorageProviderName) => Promise<void>,
isWindowSizeMediumOrLarger: boolean,
disabled: boolean,
isLoading: boolean,
onOpenContextMenu: (
event: ClientCoordinates,
@@ -76,6 +77,7 @@ export const ProjectFileListItem = ({
storageProviders,
onOpenProject,
isWindowSizeMediumOrLarger,
disabled,
isLoading,
onOpenContextMenu,
}: ProjectFileListItemProps) => {
@@ -86,12 +88,20 @@ export const ProjectFileListItem = ({
file.storageProviderName
);
const onWillOpenContextMenu = React.useCallback(
(event: ClientCoordinates, file: FileMetadataAndStorageProviderName) => {
if (disabled) return;
onOpenContextMenu(event, file);
},
[disabled, onOpenContextMenu]
);
const longTouchForContextMenuProps = useLongTouch(
React.useCallback(
event => {
onOpenContextMenu(event, file);
onWillOpenContextMenu(event, file);
},
[onOpenContextMenu, file]
[onWillOpenContextMenu, file]
)
);
return (
@@ -102,10 +112,11 @@ export const ProjectFileListItem = ({
button
key={file.fileMetadata.fileIdentifier}
onClick={() => {
if (disabled) return;
onOpenProject(file);
}}
style={styles.listItem}
onContextMenu={event => onOpenContextMenu(event, file)}
onContextMenu={event => onWillOpenContextMenu(event, file)}
{...longTouchForContextMenuProps}
>
{isWindowSizeMediumOrLarger ? (
@@ -158,7 +169,7 @@ export const ProjectFileListItem = ({
onClick={event => {
// prevent triggering the click on the list item.
event.stopPropagation();
onOpenContextMenu(event, file);
onWillOpenContextMenu(event, file);
}}
>
<ThreeDotsMenu />

View File

@@ -146,7 +146,6 @@ const CreateSection = ({
const [initialWidgetToScrollTo, setInitialWidgetToScrollTo] = React.useState(
null
);
const [showAllGameTemplates, setShowAllGameTemplates] = React.useState(false);
const { routeArguments, removeRouteArguments } = React.useContext(
RouterContext
);
@@ -315,7 +314,7 @@ const CreateSection = ({
const isCurrentProjectOpened =
!!project &&
!!currentFileMetadata &&
fileMetadata.gameId === currentFileMetadata.gameId;
fileMetadata.fileIdentifier === currentFileMetadata.fileIdentifier;
if (isCurrentProjectOpened) {
const result = await showConfirmation({
@@ -445,26 +444,6 @@ const CreateSection = ({
);
}
if (showAllGameTemplates) {
return (
<SectionContainer
backAction={() => setShowAllGameTemplates(false)}
flexBody
>
<SectionRow expand>
<ExampleStore
onSelectExampleShortHeader={onSelectExampleShortHeader}
onSelectPrivateGameTemplateListingData={
onSelectPrivateGameTemplateListingData
}
i18n={i18n}
columnsCount={getExampleItemsColumns(windowSize, isLandscape)}
/>
</SectionRow>
</SectionContainer>
);
}
return (
<I18n>
{({ i18n }) => (
@@ -610,7 +589,7 @@ const CreateSection = ({
<Trans>Start from a template</Trans>
</Text>
<FlatButton
onClick={() => setShowAllGameTemplates(true)}
onClick={onOpenNewProjectSetupDialog}
label={
isMobile ? (
<Trans>Browse</Trans>

View File

@@ -22,19 +22,18 @@ import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext'
import { SECTION_DESKTOP_SPACING } from './SectionContainer';
const iconSize = 20;
const iconButtonPaddingTop = 8;
const iconButtonPadding = 8;
/**
* Padding bottom is bigger than padding top to leave space for the Android/iOS
* bottom navigation bar.
*/
const iconButtonPaddingBottom = 20;
const iconButtonPaddingHorizontal = 8;
const iconButtonMarginBottom = 12;
const iconButtonLabelPadding = 4;
const toolbarHeight =
iconSize +
2 * iconButtonLabelPadding +
iconButtonPaddingTop +
iconButtonPaddingBottom;
2 * iconButtonPadding +
iconButtonMarginBottom;
export const homepageDesktopMenuBarWidth = 230;
export const homepageMediumMenuBarWidth =
verticalTabButtonSize + 2 * marginsSize;
@@ -62,7 +61,8 @@ export const styles = {
marginBottom: 'env(safe-area-inset-bottom)',
},
mobileButton: {
padding: `${iconButtonPaddingTop}px ${iconButtonPaddingHorizontal}px ${iconButtonPaddingBottom}px ${iconButtonPaddingHorizontal}px`,
padding: iconButtonPadding,
marginBottom: iconButtonMarginBottom,
fontSize: 'inherit',
},
buttonLabel: { padding: iconButtonLabelPadding, display: 'flex' },

View File

@@ -37,6 +37,7 @@ const styles = {
},
container: {
flex: 1,
overflowX: 'hidden',
},
scrollContainer: {
overflowY: 'scroll', // Force a scrollbar to prevent layout shifts.

View File

@@ -167,6 +167,7 @@ const TeamMemberProjectsView = ({
file={file}
currentFileMetadata={currentFileMetadata}
key={file.fileMetadata.fileIdentifier}
disabled={false}
isLoading={false}
onOpenContextMenu={openContextMenu}
onOpenProject={onOpenRecentFile}

View File

@@ -12,6 +12,9 @@ import {
} from './BaseEditor';
import { ProjectScopedContainersAccessor } from '../../InstructionOrExpression/EventsScope';
import { type ObjectWithContext } from '../../ObjectsList/EnumerateObjects';
import { switchToSceneEdition } from '../../EmbeddedGame/EmbeddedGameFrame';
const gameEditorMode = 'embedded-game'; // TODO: move to a preference.
export class SceneEditorContainer extends React.Component<RenderEditorContainerProps> {
editor: ?SceneEditor;
@@ -32,6 +35,10 @@ export class SceneEditorContainer extends React.Component<RenderEditorContainerP
if (this.props.isActive) {
const { projectItemName } = this.props;
this.props.setPreviewedLayout(projectItemName);
if (gameEditorMode === 'embedded-game' && projectItemName) {
switchToSceneEdition({ sceneName: projectItemName });
}
}
}
@@ -39,6 +46,10 @@ export class SceneEditorContainer extends React.Component<RenderEditorContainerP
if (!prevProps.isActive && this.props.isActive) {
const { projectItemName } = this.props;
this.props.setPreviewedLayout(projectItemName);
if (gameEditorMode === 'embedded-game' && projectItemName) {
switchToSceneEdition({ sceneName: projectItemName });
}
}
}
@@ -144,6 +155,7 @@ export class SceneEditorContainer extends React.Component<RenderEditorContainerP
}
onOpenEvents={this.props.onOpenEvents}
isActive={isActive}
previewDebuggerServer={this.props.previewDebuggerServer}
hotReloadPreviewButtonProps={this.props.hotReloadPreviewButtonProps}
openBehaviorEvents={this.props.openBehaviorEvents}
onExtractAsExternalLayout={this.props.onExtractAsExternalLayout}

View File

@@ -16,6 +16,7 @@ import {
ClosableTab,
type ClosableTabProps,
} from '../../UI/ClosableTabs';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
const DragSourceAndDropTarget = makeDragSourceAndDropTarget<EditorTab>(
'draggable-closable-tab'
@@ -46,6 +47,7 @@ export function DraggableEditorTabs({
onDropTab,
}: DraggableEditorTabsProps) {
let draggedTabIndex: ?number = null;
const { windowSize } = useResponsiveWindowSize();
const currentTab = getCurrentTab(editorTabs);
@@ -54,10 +56,15 @@ export function DraggableEditorTabs({
if (!currentTab) return;
const tabElement = document.getElementById(getTabId(currentTab));
if (tabElement) {
tabElement.scrollIntoView();
tabElement.scrollIntoView({
behavior: 'smooth',
// Use 'end' to keep "Home" tab visible on small screens
// when opening a new project.
inline: windowSize === 'small' ? 'end' : 'nearest',
});
}
},
[currentTab]
[currentTab, windowSize]
);
return (

View File

@@ -48,6 +48,8 @@ export type EditorTab = {|
extraEditorProps: ?EditorContainerExtraProps,
/** If set to false, the tab can't be closed. */
closable: boolean,
/** If set to true, `pointer-events: none` is applied to the tab content. */
removePointerEvents: boolean,
|};
export type EditorTabsState = {|
@@ -91,6 +93,7 @@ export type EditorOpeningOptions = {|
extraEditorProps?: EditorContainerExtraProps,
dontFocusTab?: boolean,
closable?: boolean,
removePointerEvents?: boolean,
|};
export const getEditorTabMetadata = (
@@ -138,6 +141,7 @@ export const openEditorTab = (
key,
extraEditorProps,
dontFocusTab,
removePointerEvents,
closable,
}: EditorOpeningOptions
): EditorTabsState => {
@@ -163,6 +167,7 @@ export const openEditorTab = (
extraEditorProps,
editorRef: null,
closable: typeof closable === 'undefined' ? true : !!closable,
removePointerEvents: !!removePointerEvents,
};
return {

View File

@@ -4,6 +4,7 @@ import {
type PreviewDebuggerServer,
type DebuggerId,
type HotReloaderLog,
type DebuggerStatus,
} from '../ExportAndShare/PreviewLauncher.flow';
/** Represents what should be run when a preview is launched */
@@ -22,26 +23,31 @@ export type PreviewState = {|
|};
type PreviewDebuggerServerWatcherResults = {|
previewDebuggerIds: Array<DebuggerId>,
hasNonEditionPreviewsRunning: boolean,
hotReloadLogs: Array<HotReloaderLog>,
clearHotReloadLogs: () => void,
hardReloadAllPreviews: () => void,
|};
/**
* Return the ids of the debuggers being run, watching for changes (new
* Return the status of the debuggers being run, watching for changes (new
* debugger launched or existing one closed).
*/
export const usePreviewDebuggerServerWatcher = (
previewDebuggerServer: ?PreviewDebuggerServer
): PreviewDebuggerServerWatcherResults => {
const [debuggerIds, setDebuggerIds] = React.useState<Array<DebuggerId>>([]);
const [debuggerStatus, setDebuggerStatus] = React.useState<{
[DebuggerId]: DebuggerStatus,
}>({});
const [hotReloadLogs, setHotReloadLogs] = React.useState<
Array<HotReloaderLog>
>([]);
React.useEffect(
() => {
if (!previewDebuggerServer) {
setDebuggerIds([]);
setDebuggerStatus({});
return;
}
@@ -50,10 +56,24 @@ export const usePreviewDebuggerServerWatcher = (
// Nothing to do.
},
onConnectionClosed: ({ id, debuggerIds }) => {
setDebuggerIds([...debuggerIds]);
// Remove the debugger status.
setDebuggerStatus(debuggerStatus => {
const {
[id]: closedDebuggerStatus,
...otherDebuggerStatus
} = debuggerStatus;
console.info(
`Connection closed with preview #${id}. Last status was:`,
closedDebuggerStatus
);
return otherDebuggerStatus;
});
},
onConnectionOpened: ({ id, debuggerIds }) => {
setDebuggerIds([...debuggerIds]);
// Ask the new debugger client for its status (but don't assume anything
// at this stage).
previewDebuggerServer.sendMessage(id, { command: 'getStatus' });
},
onConnectionErrored: ({ id }) => {
// Nothing to do (onConnectionClosed is called if necessary).
@@ -64,6 +84,15 @@ export const usePreviewDebuggerServerWatcher = (
onHandleParsedMessage: ({ id, parsedMessage }) => {
if (parsedMessage.command === 'hotReloader.logs') {
setHotReloadLogs(parsedMessage.payload);
} else if (parsedMessage.command === 'status') {
setDebuggerStatus(debuggerStatus => ({
...debuggerStatus,
[id]: {
isPaused: !!parsedMessage.payload.isPaused,
isInGameEdition: !!parsedMessage.payload.isInGameEdition,
sceneName: parsedMessage.payload.sceneName,
},
}));
}
},
});
@@ -77,5 +106,28 @@ export const usePreviewDebuggerServerWatcher = (
setHotReloadLogs,
]);
return { previewDebuggerIds: debuggerIds, hotReloadLogs, clearHotReloadLogs };
const hardReloadAllPreviews = React.useCallback(
() => {
if (!previewDebuggerServer) return;
console.info('Hard reloading all previews...');
previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => {
previewDebuggerServer.sendMessage(debuggerId, {
command: 'hardReload',
});
});
},
[previewDebuggerServer]
);
const hasNonEditionPreviewsRunning = Object.keys(debuggerStatus).some(
key => !debuggerStatus[+key].isInGameEdition
);
return {
hasNonEditionPreviewsRunning,
hotReloadLogs,
clearHotReloadLogs,
hardReloadAllPreviews,
};
};

View File

@@ -19,7 +19,12 @@ type Props = {|
const DRAGGABLE_PART_CLASS_NAME = 'title-bar-draggable-part';
const styles = {
container: { display: 'flex', flexShrink: 0, alignItems: 'flex-end' },
container: {
display: 'flex',
flexShrink: 0,
alignItems: 'flex-end',
position: 'relative',
},
leftSideArea: { alignSelf: 'stretch', flexShrink: 0 },
rightSideArea: { alignSelf: 'stretch', flex: 1 },
menuIcon: { marginLeft: 4, marginRight: 4 },

View File

@@ -87,21 +87,21 @@ const PreviewAndShareButtons = React.memo<PreviewAndShareButtonsProps>(
click: async () => {
await onPreviewWithoutHotReload({ numberOfWindows: 2 });
},
enabled: isPreviewEnabled && !hasPreviewsRunning,
enabled: isPreviewEnabled,
},
{
label: i18n._(t`3 previews in 3 windows`),
click: async () => {
onPreviewWithoutHotReload({ numberOfWindows: 3 });
},
enabled: isPreviewEnabled && !hasPreviewsRunning,
enabled: isPreviewEnabled,
},
{
label: i18n._(t`4 previews in 4 windows`),
click: async () => {
onPreviewWithoutHotReload({ numberOfWindows: 4 });
},
enabled: isPreviewEnabled && !hasPreviewsRunning,
enabled: isPreviewEnabled,
},
],
},
@@ -185,7 +185,9 @@ const PreviewAndShareButtons = React.memo<PreviewAndShareButtonsProps>(
<LineStackLayout noMargin>
<FlatButtonWithSplitMenu
primary
onClick={onHotReloadPreview}
onClick={
hasPreviewsRunning ? onHotReloadPreview : onPreviewWithoutHotReload
}
disabled={!isPreviewEnabled}
icon={hasPreviewsRunning ? <UpdateIcon /> : <PreviewIcon />}
label={

View File

@@ -103,6 +103,7 @@ import {
type StorageProviderOperations,
type FileMetadata,
type SaveAsLocation,
type SaveAsOptions,
type FileMetadataAndStorageProviderName,
type ResourcesActionsMenuBuilder,
} from '../ProjectsStorage';
@@ -199,6 +200,7 @@ import { type ObjectWithContext } from '../ObjectsList/EnumerateObjects';
import useGamesList from '../GameDashboard/UseGamesList';
import useCapturesManager from './UseCapturesManager';
import useHomepageWitchForRouting from './UseHomepageWitchForRouting';
import { EmbeddedGameFrame } from '../EmbeddedGame/EmbeddedGameFrame';
const GD_STARTUP_TIMES = global.GD_STARTUP_TIMES || [];
@@ -304,7 +306,9 @@ export type Props = {|
resourceExternalEditors: Array<ResourceExternalEditor>,
requestUpdate?: () => void,
renderShareDialog: ShareDialogWithoutExportsProps => React.Node,
renderGDJSDevelopmentWatcher?: ?() => React.Node,
renderGDJSDevelopmentWatcher?: ?({|
onGDJSUpdated: () => Promise<void> | void,
|}) => React.Node,
extensionsLoader?: JsExtensionsLoader,
initialFileMetadataToOpen: ?FileMetadata,
initialExampleSlugToOpen: ?string,
@@ -408,11 +412,11 @@ const MainFrame = (props: Props) => {
_previewLauncher.current &&
_previewLauncher.current.getPreviewDebuggerServer();
const {
previewDebuggerIds,
hasNonEditionPreviewsRunning,
hotReloadLogs,
clearHotReloadLogs,
hardReloadAllPreviews,
} = usePreviewDebuggerServerWatcher(previewDebuggerServer);
const hasPreviewsRunning = !!previewDebuggerIds.length;
const {
ensureInteractionHappened,
renderOpenConfirmDialog,
@@ -619,6 +623,10 @@ const MainFrame = (props: Props) => {
<ExtensionIcon />
) : null;
// Scene editors can have an embedded game, so they redefine manually
// which components can have clicks/touches.
const removePointerEvents = kind === 'layout';
const closable = kind !== 'start page';
const extraEditorProps =
kind === 'start page'
@@ -636,6 +644,7 @@ const MainFrame = (props: Props) => {
)
: null,
closable,
removePointerEvents,
label,
projectItemName: name,
tabOptions,
@@ -1606,6 +1615,7 @@ const MainFrame = (props: Props) => {
fullLoadingScreen,
forceDiagnosticReport,
launchCaptureOptions,
isForInGameEdition,
}: LaunchPreviewOptions) => {
if (!currentProject) return;
if (currentProject.getLayoutsCount() === 0) return;
@@ -1616,10 +1626,14 @@ const MainFrame = (props: Props) => {
setPreviewLoading(true);
notifyPreviewOrExportWillStart(state.editorTabs);
const layoutName = previewState.isPreviewOverriden
const layoutName = isForInGameEdition
? isForInGameEdition.forcedSceneName
: previewState.isPreviewOverriden
? previewState.overridenPreviewLayoutName
: previewState.previewLayoutName;
const externalLayoutName = previewState.isPreviewOverriden
const externalLayoutName = isForInGameEdition
? isForInGameEdition.forcedExternalLayoutName
: previewState.isPreviewOverriden
? previewState.overridenPreviewExternalLayoutName
: previewState.previewExternalLayoutName;
@@ -1661,6 +1675,7 @@ const MainFrame = (props: Props) => {
const startTime = Date.now();
await previewLauncher.launchPreview({
project: currentProject,
// TODO: replace by scene name and external layout name
layout,
externalLayout,
networkPreview: !!networkPreview,
@@ -1671,23 +1686,25 @@ const MainFrame = (props: Props) => {
authenticatedPlayer,
getIsMenuBarHiddenInPreview: preferences.getIsMenuBarHiddenInPreview,
getIsAlwaysOnTopInPreview: preferences.getIsAlwaysOnTopInPreview,
numberOfWindows: numberOfWindows || 1,
numberOfWindows: numberOfWindows === undefined ? 1 : numberOfWindows,
isForInGameEdition: !!isForInGameEdition,
captureOptions,
onCaptureFinished,
});
setPreviewLoading(false);
sendPreviewStarted({
quickCustomizationGameId:
quickCustomizationDialogOpenedFromGameId || null,
networkPreview: !!networkPreview,
hotReload: !!hotReload,
projectDataOnlyExport: !!projectDataOnlyExport,
fullLoadingScreen: !!fullLoadingScreen,
numberOfWindows: numberOfWindows || 1,
forceDiagnosticReport: !!forceDiagnosticReport,
previewLaunchDuration: Date.now() - startTime,
});
if (!isForInGameEdition)
sendPreviewStarted({
quickCustomizationGameId:
quickCustomizationDialogOpenedFromGameId || null,
networkPreview: !!networkPreview,
hotReload: !!hotReload,
projectDataOnlyExport: !!projectDataOnlyExport,
fullLoadingScreen: !!fullLoadingScreen,
numberOfWindows: numberOfWindows || 1,
forceDiagnosticReport: !!forceDiagnosticReport,
previewLaunchDuration: Date.now() - startTime,
});
if (inAppTutorialOrchestratorRef.current) {
inAppTutorialOrchestratorRef.current.onPreviewLaunch();
@@ -1771,6 +1788,47 @@ const MainFrame = (props: Props) => {
[launchPreview]
);
const onLaunchPreviewForInGameEdition = React.useCallback(
({
sceneName,
externalLayoutName,
}: {|
sceneName: string,
externalLayoutName: ?string,
|}) => {
launchPreview({
networkPreview: false,
hotReload: false,
forceDiagnosticReport: false,
isForInGameEdition: {
forcedSceneName: sceneName,
forcedExternalLayoutName: externalLayoutName,
},
numberOfWindows: 0,
});
},
[launchPreview]
);
const relaunchAndThenHardReloadAllPreviews = React.useCallback(
async () => {
// Build a new preview (so that any changes in runtime files are picked up)
// and then ask all previews to "hard reload" themselves (i.e: refresh their page).
await launchPreview({
networkPreview: false,
hotReload: false,
forceDiagnosticReport: false,
numberOfWindows: 0,
});
hardReloadAllPreviews();
},
[
hardReloadAllPreviews,
launchPreview,
]
);
const launchQuickCustomizationPreview = React.useCallback(
() =>
launchPreview({
@@ -1789,7 +1847,7 @@ const MainFrame = (props: Props) => {
const hotReloadPreviewButtonProps: HotReloadPreviewButtonProps = React.useMemo(
() => ({
hasPreviewsRunning,
hasPreviewsRunning: hasNonEditionPreviewsRunning,
launchProjectWithLoadingScreenPreview: () =>
launchPreview({ fullLoadingScreen: true }),
launchProjectDataOnlyPreview: () =>
@@ -1797,7 +1855,7 @@ const MainFrame = (props: Props) => {
launchProjectCodeAndDataPreview: () =>
launchPreview({ hotReload: true, projectDataOnlyExport: false }),
}),
[hasPreviewsRunning, launchPreview]
[hasNonEditionPreviewsRunning, launchPreview]
);
const getEditorsTabStateWithScene = React.useCallback(
@@ -2604,15 +2662,27 @@ const MainFrame = (props: Props) => {
try {
let newSaveAsLocation: ?SaveAsLocation =
options && options.forcedSavedAsLocation;
let newSaveAsOptions: ?SaveAsOptions = null;
if (onChooseSaveProjectAsLocation && !newSaveAsLocation) {
const { saveAsLocation } = await onChooseSaveProjectAsLocation({
const {
saveAsLocation,
saveAsOptions,
} = await onChooseSaveProjectAsLocation({
project: currentProject,
fileMetadata: currentFileMetadata,
displayOptionToGenerateNewProjectUuid:
// No need to display the option if current file metadata doesn't have
// a gameId...
!!currentFileMetadata &&
!!currentFileMetadata.gameId &&
// ... or if the project is opened from a URL.
oldStorageProvider.internalName !== 'UrlStorageProvider',
});
if (!saveAsLocation) {
return; // Save as was cancelled.
}
newSaveAsLocation = saveAsLocation;
newSaveAsOptions = saveAsOptions;
}
if (canFileMetadataBeSafelySavedAs && currentFileMetadata) {
@@ -2627,6 +2697,21 @@ const MainFrame = (props: Props) => {
if (!canProjectBeSafelySavedAs) return;
}
let originalProjectUuid = null;
if (newSaveAsOptions && newSaveAsOptions.generateNewProjectUuid) {
originalProjectUuid = currentProject.getProjectUuid();
currentProject.resetProjectUuid();
}
let originalProjectName = null;
const newProjectName =
newSaveAsLocation && newSaveAsLocation.name
? newSaveAsLocation.name
: null;
if (newProjectName) {
originalProjectName = currentProject.getName();
currentProject.setName(newProjectName);
}
const { wasSaved, fileMetadata } = await onSaveProjectAs(
currentProject,
newSaveAsLocation,
@@ -2649,7 +2734,13 @@ const MainFrame = (props: Props) => {
}
);
if (!wasSaved) return; // Save was cancelled, don't do anything.
if (!wasSaved) {
_replaceSnackMessage(i18n._(t`An error occurred. Please try again.`));
if (originalProjectName) currentProject.setName(originalProjectName);
if (originalProjectUuid)
currentProject.setProjectUuid(originalProjectUuid);
return;
}
sealUnsavedChanges({ setCheckpointTime: true });
_replaceSnackMessage(i18n._(t`Project properly saved`));
@@ -3441,7 +3532,7 @@ const MainFrame = (props: Props) => {
previewEnabled:
!!state.currentProject && state.currentProject.getLayoutsCount() > 0,
onOpenProjectManager: toggleProjectManager,
hasPreviewsRunning,
hasPreviewsRunning: hasNonEditionPreviewsRunning,
allowNetworkPreview:
!!_previewLauncher.current &&
_previewLauncher.current.canDoNetworkPreview(),
@@ -3536,6 +3627,30 @@ const MainFrame = (props: Props) => {
'main-frame' /* The root styling, done in CSS to read some CSS variables. */
}
>
{!!renderPreviewLauncher &&
renderPreviewLauncher(
{
crashReportUploadLevel:
preferences.values.previewCrashReportUploadLevel ||
'exclude-javascript-code-events',
previewContext: quickCustomizationDialogOpenedFromGameId
? 'preview-quick-customization'
: 'preview',
sourceGameId: quickCustomizationDialogOpenedFromGameId || '',
getIncludeFileHashs:
eventsFunctionsExtensionsContext.getIncludeFileHashs,
onExport: () => openShareDialog('publish'),
onCaptureFinished,
},
(previewLauncher: ?PreviewLauncherInterface) => {
_previewLauncher.current = previewLauncher;
}
)}
<EmbeddedGameFrame
key={currentProject ? currentProject.ptr : 0}
previewDebuggerServer={previewDebuggerServer || null}
onLaunchPreviewForInGameEdition={onLaunchPreviewForInGameEdition}
/>
{!!renderMainMenu &&
renderMainMenu(
{ ...buildMainMenuProps, isApplicationTopLevelMenu: true },
@@ -3643,7 +3758,7 @@ const MainFrame = (props: Props) => {
!checkedOutVersionStatus && !cloudProjectRecoveryOpenedVersionId
}
onOpenDebugger={launchDebuggerAndPreview}
hasPreviewsRunning={hasPreviewsRunning}
hasPreviewsRunning={hasNonEditionPreviewsRunning}
onPreviewWithoutHotReload={launchNewPreview}
onNetworkPreview={launchNetworkPreview}
onHotReloadPreview={launchHotReloadPreview}
@@ -3672,7 +3787,11 @@ const MainFrame = (props: Props) => {
const errorBoundaryProps = getEditorErrorBoundaryProps(editorTab.key);
return (
<TabContentContainer key={editorTab.key} active={isCurrentTab}>
<TabContentContainer
key={editorTab.key}
active={isCurrentTab}
removePointerEvents={editorTab.removePointerEvents}
>
<CommandsContextScopedProvider active={isCurrentTab}>
<ErrorBoundary
componentTitle={errorBoundaryProps.componentTitle}
@@ -3803,7 +3922,11 @@ const MainFrame = (props: Props) => {
<LoaderModal
show={showLoader}
progress={fileMetadataOpeningProgress}
message={loaderModalOpeningMessage || fileMetadataOpeningMessage}
message={
loaderModalOpeningMessage ||
fileMetadataOpeningMessage ||
(previewLoading ? t`Loading preview...` : null)
}
/>
<Snackbar
open={state.snackMessageOpen}
@@ -3826,25 +3949,6 @@ const MainFrame = (props: Props) => {
initialTab: shareDialogInitialTab,
gamesList,
})}
{!!renderPreviewLauncher &&
renderPreviewLauncher(
{
crashReportUploadLevel:
preferences.values.previewCrashReportUploadLevel ||
'exclude-javascript-code-events',
previewContext: quickCustomizationDialogOpenedFromGameId
? 'preview-quick-customization'
: 'preview',
sourceGameId: quickCustomizationDialogOpenedFromGameId || '',
getIncludeFileHashs:
eventsFunctionsExtensionsContext.getIncludeFileHashs,
onExport: () => openShareDialog('publish'),
onCaptureFinished,
},
(previewLauncher: ?PreviewLauncherInterface) => {
_previewLauncher.current = previewLauncher;
}
)}
{chooseResourceOptions && onResourceChosen && !!currentProject && (
<NewResourceDialog
project={currentProject}
@@ -3977,7 +4081,9 @@ const MainFrame = (props: Props) => {
)}
{state.gdjsDevelopmentWatcherEnabled &&
renderGDJSDevelopmentWatcher &&
renderGDJSDevelopmentWatcher()}
renderGDJSDevelopmentWatcher({
onGDJSUpdated: relaunchAndThenHardReloadAllPreviews,
})}
{!!hotReloadLogs.length && (
<HotReloadLogsDialog
logs={hotReloadLogs}

View File

@@ -17,6 +17,8 @@ import {
getRequirementsErrors,
isMarketingPlanActive,
} from './MarketingPlanUtils';
import MarketingPlansDialog from './MarketingPlansDialog';
import Link from '../UI/Link';
const styles = {
container: {
@@ -40,6 +42,10 @@ const MarketingPlanSingleDisplay = ({
}: Props) => {
const gdevelopTheme = React.useContext(GDevelopThemeContext);
const [
marketingPlansDialogOpen,
setMarketingPlansDialogOpen,
] = React.useState<boolean>(false);
const activeGameFeaturings: ?(GameFeaturing[]) = React.useMemo(
() => {
if (!gameFeaturings) return null;
@@ -90,11 +96,21 @@ const MarketingPlanSingleDisplay = ({
</Paper>
<Text size="body-small" color="inherit">
<Trans>
Get ready-made packs to make your game visible to the GDevelop
community.
Make your game visible to the GDevelop community and to the world
with{' '}
<Link onClick={() => setMarketingPlansDialogOpen(true)} href="#">
<Trans>Marketing Boosts</Trans>
</Link>
.
</Trans>
</Text>
</Column>
{marketingPlansDialogOpen && game && (
<MarketingPlansDialog
game={game}
onClose={() => setMarketingPlansDialogOpen(false)}
/>
)}
</div>
);
};

View File

@@ -123,8 +123,8 @@ const MarketingPlans = ({ game }: Props) => {
<ColumnStackLayout noMargin>
<Text color="secondary" noMargin>
<Trans>
Get ready-made packs to make your game visible to the GDevelop
community.{' '}
Make your game visible to the GDevelop community and to the
world with Marketing Boosts.{' '}
<Link
href="https://wiki.gdevelop.io/gdevelop5/interface/games-dashboard/marketing"
onClick={() =>

View File

@@ -307,7 +307,7 @@ const CollisionMasksEditor = ({
if (!animations.getAnimationsCount()) return null;
const resourceName = sprite ? sprite.getImageName() : '';
const editors: { [string]: Editor } = {
const editors: { [string]: Editor | null } = {
preview: {
type: 'primary',
noTitleBar: true,

View File

@@ -205,7 +205,7 @@ const PointsEditor = ({
if (!animations.getAnimationsCount()) return null;
const resourceName = sprite ? sprite.getImageName() : '';
const editors: { [string]: Editor } = {
const editors: { [string]: Editor | null } = {
preview: {
type: 'primary',
noTitleBar: true,

View File

@@ -13,6 +13,7 @@ import AchievementList from './AchievementList';
import Trophy from '../../UI/CustomSvgIcons/Trophy';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
import PlaceholderLoader from '../../UI/PlaceholderLoader';
import { Column } from '../../UI/Grid';
type Props = {|
achievements: ?Array<Achievement>,
@@ -46,47 +47,52 @@ const UserAchievements = ({
const { isMobile } = useResponsiveWindowSize();
return (
<ResponsiveLineStackLayout>
{!!badges && !!achievements ? (
<>
<div style={styles.leftContainer}>
<div
style={{
...styles.summary,
padding: isMobile ? '0 20' : '20',
}}
>
<Trophy color="secondary" fontSize="large" />
<Text size="block-title">
<Trans>
{badges.length}/{achievements.length} achievements
</Trans>
{badges.length === 0 && (
<>
{' '}
<Trans>(yet!)</Trans>
</>
<Column expand noMargin>
<Text size="block-title">
<Trans>Achievements</Trans>
</Text>
<ResponsiveLineStackLayout>
{!!badges && !!achievements ? (
<>
<div style={styles.leftContainer}>
<div
style={{
...styles.summary,
padding: isMobile ? '0 20' : '20',
}}
>
<Trophy color="secondary" fontSize="large" />
<Text size="block-title">
<Trans>
{badges.length}/{achievements.length} achievements
</Trans>
{badges.length === 0 && (
<>
{' '}
<Trans>(yet!)</Trans>
</>
)}
</Text>
</div>
</div>
{badges.length > 0 && (
<div style={styles.rightContainer}>
{badges && achievements && (
<AchievementList
badges={badges}
achievements={achievements}
displayUnclaimedAchievements={displayUnclaimedAchievements}
displayNotifications={displayNotifications}
/>
)}
</Text>
</div>
</div>
{badges.length > 0 && (
<div style={styles.rightContainer}>
{badges && achievements && (
<AchievementList
badges={badges}
achievements={achievements}
displayUnclaimedAchievements={displayUnclaimedAchievements}
displayNotifications={displayNotifications}
/>
)}
</div>
)}
</>
) : (
<PlaceholderLoader />
)}
</ResponsiveLineStackLayout>
</div>
)}
</>
) : (
<PlaceholderLoader />
)}
</ResponsiveLineStackLayout>
</Column>
);
};

View File

@@ -245,7 +245,7 @@ const ContributionDetails = ({ userId }: Props) => {
</Column>
</>
) : (
<PlaceholderLoader />
<PlaceholderLoader style={{ minHeight: 205 }} />
)}
</Column>
);

View File

@@ -161,7 +161,6 @@ const CurrentUsageDisplayer = ({
!isFeatureLocked ? 'Build limit reached' : 'Unlock build type'
}
label={<Trans>Upgrade your subscription</Trans>}
makeButtonRaised
payWithCreditsOptions={
hidePurchaseWithCredits
? undefined
@@ -208,7 +207,6 @@ const CurrentUsageDisplayer = ({
!isFeatureLocked ? 'Build limit reached' : 'Unlock build type'
}
label={<Trans>Get a subscription</Trans>}
makeButtonRaised={quota.limitReached}
payWithCreditsOptions={
!quota.limitReached || hidePurchaseWithCredits
? undefined

View File

@@ -181,7 +181,7 @@ const ProfileDialog = ({ open, onClose }: Props) => {
authenticatedUser.onOpenChangeEmailDialog
}
/>
{isStudentAccount ? null : subscriptionPlansWithPricingSystems ? (
{isStudentAccount ? null : (
<SubscriptionDetails
subscription={authenticatedUser.subscription}
subscriptionPlansWithPricingSystems={
@@ -190,8 +190,6 @@ const ProfileDialog = ({ open, onClose }: Props) => {
onManageSubscription={onManageSubscription}
isManageSubscriptionLoading={isManageSubscriptionLoading}
/>
) : (
<PlaceholderLoader />
)}
{!isStudentAccount && !isTeacherAccount && (
<Column noMargin>

View File

@@ -1,21 +1,26 @@
// @flow
import * as React from 'react';
import { Trans } from '@lingui/macro';
import Text from '../../UI/Text';
import { Column } from '../../UI/Grid';
import { Column, Line } from '../../UI/Grid';
import { ResponsiveLineStackLayout } from '../../UI/Layout';
import Link from '../../UI/Link';
import { type SubscriptionDialogDisplayReason } from '../../Utils/Analytics/EventSender';
import { SubscriptionSuggestionContext } from './SubscriptionSuggestionContext';
import RaisedButton from '../../UI/RaisedButton';
import FlatButton from '../../UI/FlatButton';
import Coin from '../../Credits/Icons/Coin';
import { CalloutCard } from '../../UI/CalloutCard';
import classes from './GetSubscriptionCard.module.css';
import Paper from '../../UI/Paper';
import CrownShining from '../../UI/CustomSvgIcons/CrownShining';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
const styles = {
paper: {
zIndex: 2, // Make sure the paper is above the background for the border effect.
flex: 1,
},
diamondIcon: {
width: 50,
height: 50,
width: 70,
height: 70,
},
coinIcon: {
width: 12,
@@ -29,7 +34,6 @@ type Props = {|
children: React.Node,
subscriptionDialogOpeningReason: SubscriptionDialogDisplayReason,
label?: React.Node,
makeButtonRaised?: boolean,
hideButton?: boolean,
payWithCreditsOptions?: {|
label: React.Node,
@@ -41,65 +45,54 @@ const GetSubscriptionCard = ({
children,
subscriptionDialogOpeningReason,
label,
makeButtonRaised,
hideButton,
payWithCreditsOptions,
}: Props) => {
const { openSubscriptionDialog } = React.useContext(
SubscriptionSuggestionContext
);
const { isMobile } = useResponsiveWindowSize();
return (
<CalloutCard
renderImage={style => (
<img src="res/diamond.svg" style={styles.diamondIcon} alt="diamond" />
)}
>
<Column expand justifyContent="center">
<ResponsiveLineStackLayout alignItems="center" noColumnMargin noMargin>
<Column noMargin expand>
{children}
<div className={classes.premiumContainer}>
<Paper style={styles.paper} background="medium">
<Line expand alignItems="center" noMargin={!isMobile}>
<img src="res/diamond.svg" style={styles.diamondIcon} alt="diamond" />
<Column expand justifyContent="center">
<ResponsiveLineStackLayout
alignItems="center"
noColumnMargin
noMargin
>
<Column noMargin expand>
{children}
</Column>
{payWithCreditsOptions && (
<FlatButton
leftIcon={<Coin style={styles.coinIcon} />}
label={payWithCreditsOptions.label}
primary
onClick={payWithCreditsOptions.onPayWithCredits}
/>
)}
{!hideButton && (
<RaisedButton
label={label || <Trans>Upgrade</Trans>}
primary
onClick={() => {
openSubscriptionDialog({
analyticsMetadata: {
reason: subscriptionDialogOpeningReason,
},
});
}}
icon={<CrownShining fontSize="small" />}
/>
)}
</ResponsiveLineStackLayout>
</Column>
{payWithCreditsOptions && (
<FlatButton
leftIcon={<Coin style={styles.coinIcon} />}
label={payWithCreditsOptions.label}
primary
onClick={payWithCreditsOptions.onPayWithCredits}
/>
)}
{!hideButton &&
(!makeButtonRaised ? (
<Link
href="#"
onClick={() => {
openSubscriptionDialog({
analyticsMetadata: {
reason: subscriptionDialogOpeningReason,
},
});
}}
>
<Text noMargin color="inherit">
{label || <Trans>Upgrade</Trans>}
</Text>
</Link>
) : (
<RaisedButton
label={label || <Trans>Upgrade</Trans>}
primary
onClick={() => {
openSubscriptionDialog({
analyticsMetadata: {
reason: subscriptionDialogOpeningReason,
},
});
}}
/>
))}
</ResponsiveLineStackLayout>
</Column>
</CalloutCard>
</Line>
</Paper>
</div>
);
};

View File

@@ -0,0 +1,19 @@
.premiumContainer {
position: relative;
overflow: hidden;
padding: 2px;
border-radius: 10px;
display: flex;
}
.premiumContainer::before {
content: '';
display: block;
background: linear-gradient(90deg, var(--theme-premium-teal) 0%, var(--theme-premium-orange) 100%);
width: 100%;
padding-bottom: 100%;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}

View File

@@ -1,17 +1,17 @@
// @flow
import * as React from 'react';
import { I18n } from '@lingui/react';
import {
type SubscriptionPlan,
type SubscriptionPlanWithPricingSystems,
type SubscriptionPlanPricingSystem,
} from '../../Utils/GDevelopServices/Usage';
import Divider from '@material-ui/core/Divider';
import Text from '../../UI/Text';
import { Column, Line, Spacer } from '../../UI/Grid';
import { Trans } from '@lingui/macro';
import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext';
import { ColumnStackLayout } from '../../UI/Layout';
import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout';
import CheckCircle from '../../UI/CustomSvgIcons/CheckCircle';
import Paper from '../../UI/Paper';
import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer';
@@ -428,20 +428,23 @@ const PlanCard = (props: Props) => {
</Line>
{mainPricingSystem && (
<Paper background="light" style={styles.planPricesPaper}>
{otherPricingSystem && (
<span style={styles.discountedPrice}>
{getPlanPrices({
pricingSystems: [
extrapolateMonthlyPricingSystemToYearlyBasis(
otherPricingSystem
),
],
})}
</span>
)}
{getPlanPrices({
pricingSystems: [mainPricingSystem],
})}
<LineStackLayout noMargin>
{otherPricingSystem && (
<span style={styles.discountedPrice}>
{getPlanPrices({
pricingSystems: [
extrapolateMonthlyPricingSystemToYearlyBasis(
otherPricingSystem
),
],
})}
</span>
)}
{otherPricingSystem && <Divider orientation="vertical" />}
{getPlanPrices({
pricingSystems: [mainPricingSystem],
})}
</LineStackLayout>
</Paper>
)}
<Spacer />

View File

@@ -63,28 +63,32 @@ const subscriptionOptions: {
title: React.Node,
description: React.Node,
icon: React.Node,
buttonColor?: 'primary' | 'premium',
|},
} = {
individual: {
title: <Trans>For Individuals</Trans>,
description: <Trans>Hobbyists and indie devs</Trans>,
icon: <IndividualPlans style={{ width: 115, height: 100 }} />,
buttonColor: 'premium',
},
team: {
title: <Trans>For Teams</Trans>,
description: <Trans>Companies, studios and agencies</Trans>,
icon: <TeamPlans style={{ width: 175, height: 100 }} />,
buttonColor: 'premium',
},
education: {
title: <Trans>For Education</Trans>,
description: <Trans>Teachers, courses and universities</Trans>,
icon: <EducationPlans style={{ width: 110, height: 100 }} />,
buttonColor: 'primary',
},
};
type Props = {
subscription: ?Subscription,
subscriptionPlansWithPricingSystems: SubscriptionPlanWithPricingSystems[],
subscriptionPlansWithPricingSystems: ?(SubscriptionPlanWithPricingSystems[]),
onManageSubscription: () => void | Promise<void>,
isManageSubscriptionLoading: boolean,
simulateNativeMobileApp?: boolean,
@@ -130,7 +134,7 @@ const SubscriptionDetails = ({
setError(null);
setIsLoadingUserPrice(true);
try {
if (!subscription) {
if (!subscription || !subscriptionPlansWithPricingSystems) {
setUserSubscriptionPlanWithPricingSystems(null);
return;
}
@@ -206,38 +210,56 @@ const SubscriptionDetails = ({
const isOnOrSimulateMobileApp =
isNativeMobileApp() || simulateNativeMobileApp;
const header = (
<Line alignItems="center">
<Column noMargin>
<Text size="block-title">
<Trans>Subscriptions</Trans>
</Text>
<Text size="body" noMargin>
<Trans>
Publish to Android, iOS, unlock more cloud projects, leaderboards,
collaboration features and more online services.{' '}
<Link
href="https://gdevelop.io/pricing#feature-comparison"
onClick={() =>
Window.openExternalURL(
'https://gdevelop.io/pricing#feature-comparison'
)
}
>
Learn more
</Link>
</Trans>
</Text>
</Column>
</Line>
);
if (error) {
return <PlaceholderError>{error}</PlaceholderError>;
return (
<Column noMargin>
{header}
<PlaceholderError>{error}</PlaceholderError>
</Column>
);
}
if (!subscription || isLoadingUserPrice) {
return <PlaceholderLoader />;
if (
!subscription ||
!subscriptionPlansWithPricingSystems ||
isLoadingUserPrice
) {
return (
<Column noMargin>
{header}
<PlaceholderLoader style={{ minHeight: 205 }} />
</Column>
);
}
return (
<Column noMargin>
<Line alignItems="center">
<Column noMargin>
<Text size="block-title">
<Trans>Subscriptions</Trans>
</Text>
<Text size="body" noMargin>
<Trans>
Publish to Android, iOS, unlock more cloud projects, leaderboards,
collaboration features and more online services.{' '}
<Link
href="https://gdevelop.io/pricing#feature-comparison"
onClick={() =>
Window.openExternalURL(
'https://gdevelop.io/pricing#feature-comparison'
)
}
>
Learn more
</Link>
</Trans>
</Text>
</Column>
</Line>
{header}
{userSubscriptionPlanWithPricingSystems &&
userSubscriptionPlanWithPricingSystems.id &&
!isSubscriptionExpired ? (
@@ -401,7 +423,12 @@ const SubscriptionDetails = ({
) : (
<ResponsiveLineStackLayout noColumnMargin noResponsiveLandscape>
{Object.keys(subscriptionOptions).map(key => {
const { title, description, icon } = subscriptionOptions[key];
const {
title,
description,
icon,
buttonColor,
} = subscriptionOptions[key];
return (
<div
style={{
@@ -433,7 +460,7 @@ const SubscriptionDetails = ({
</Column>
<Spacer />
<RaisedButton
primary
color={buttonColor}
onClick={() =>
openSubscriptionDialog({
analyticsMetadata: { reason: 'Consult profile' },

View File

@@ -20,7 +20,6 @@ import {
hasSubscriptionBeenManuallyAdded,
isSubscriptionComingFromTeam,
} from '../../Utils/GDevelopServices/Usage';
import EmptyMessage from '../../UI/EmptyMessage';
import { showErrorBox } from '../../UI/Messages/MessageBox';
import {
sendSubscriptionDialogShown,
@@ -406,9 +405,13 @@ export default function SubscriptionDialog({
.filter(Boolean)
.filter(plan => {
if (filter === 'individual') {
return ['free', 'gdevelop_silver', 'gdevelop_gold'].includes(
plan.id
);
if (isPlanValid) {
return ['free', 'gdevelop_silver', 'gdevelop_gold'].includes(
plan.id
);
} else {
return ['gdevelop_silver', 'gdevelop_gold'].includes(plan.id);
}
}
if (filter === 'team') {
return ['gdevelop_startup', 'gdevelop_enterprise'].includes(
@@ -419,13 +422,15 @@ export default function SubscriptionDialog({
return ['gdevelop_education'].includes(plan.id);
}
return plan.id !== 'gdevelop_education';
return !['gdevelop_education', 'free'].includes(plan.id);
})
: null;
const dialogMaxWidth =
!displayedSubscriptionPlanWithPricingSystems ||
displayedSubscriptionPlanWithPricingSystems.length === 1
? 'sm'
: displayedSubscriptionPlanWithPricingSystems.length < 3
? 'md'
: displayedSubscriptionPlanWithPricingSystems.length < 4
? 'lg'
@@ -463,101 +468,92 @@ export default function SubscriptionDialog({
/>,
]}
open={open}
fixedContent={
<>
{hasValidSubscriptionPlan(authenticatedUser.subscription) &&
userSubscriptionPlanWithPricingSystems && (
<Column noMargin>
<Text>
<Trans>Your plan:</Trans>
</Text>
<Paper
background="medium"
variant="outlined"
style={styles.currentPlanPaper}
>
<Line
justifyContent="space-between"
alignItems="center"
noMargin
>
<Line alignItems="center" noMargin>
{getPlanIcon({
subscriptionPlan: userSubscriptionPlanWithPricingSystems,
logoSize: 20,
})}
<Text size="block-title">
{selectMessageByLocale(
i18n,
userSubscriptionPlanWithPricingSystems.nameByLocale
)}
</Text>
</Line>
{!hasSubscriptionBeenManuallyAdded(
authenticatedUser.subscription
) &&
!isSubscriptionComingFromTeam(
authenticatedUser.subscription
) &&
!willCancelAtPeriodEnd &&
userPricingSystemId !== 'REDEMPTION_CODE' && (
<FlatButton
primary
label={<Trans>Cancel subscription</Trans>}
onClick={() =>
buyUpdateOrCancelPlan(i18n, null)
}
/>
)}
</Line>
</Paper>
</Column>
)}
<Line justifyContent="space-between" alignItems="center">
<Text size="block-title">
<Trans>Subscription plans</Trans>
</Text>
<TwoStatesButton
value={period}
leftButton={{
label: <Trans>Monthly</Trans>,
value: 'month',
}}
rightButton={{
label: <Trans>Yearly</Trans>,
value: 'year',
}}
// $FlowIgnore
onChange={setPeriod}
/>
</Line>
{period !== 'year' && maximumDiscount > 0 && (
<HotMessage
title={
<Trans>
Up to {maximumDiscount.toFixed(0)}% discount
</Trans>
}
message={
<Trans>
Get a yearly subscription and enjoy discounts up to
{maximumDiscount.toFixed(0)}%!
</Trans>
}
onClickRightButton={() => setPeriod('year')}
rightButtonLabel={
isMobile ? (
<Trans>Check out</Trans>
) : (
<Trans>See yearly subs</Trans>
)
}
/>
)}
</>
}
>
{isPlanValid && userSubscriptionPlanWithPricingSystems && (
<Column noMargin>
<Text>
<Trans>Your plan:</Trans>
</Text>
<Paper
background="medium"
variant="outlined"
style={styles.currentPlanPaper}
>
<Line
justifyContent="space-between"
alignItems="center"
noMargin
>
<Line alignItems="center" noMargin>
{getPlanIcon({
subscriptionPlan: userSubscriptionPlanWithPricingSystems,
logoSize: 20,
})}
<Text size="block-title">
{selectMessageByLocale(
i18n,
userSubscriptionPlanWithPricingSystems.nameByLocale
)}
</Text>
</Line>
{!hasSubscriptionBeenManuallyAdded(
authenticatedUser.subscription
) &&
!isSubscriptionComingFromTeam(
authenticatedUser.subscription
) &&
!willCancelAtPeriodEnd &&
userPricingSystemId !== 'REDEMPTION_CODE' && (
<FlatButton
primary
label={<Trans>Cancel subscription</Trans>}
onClick={() => buyUpdateOrCancelPlan(i18n, null)}
/>
)}
</Line>
</Paper>
</Column>
)}
<ColumnStackLayout noMargin>
<Line justifyContent="space-between" alignItems="center">
<Text size="block-title">
<Trans>Get GDevelop Premium</Trans>
</Text>
<TwoStatesButton
value={period}
leftButton={{
label: <Trans>Monthly</Trans>,
value: 'month',
}}
rightButton={{
label: <Trans>Yearly</Trans>,
value: 'year',
}}
// $FlowIgnore
onChange={setPeriod}
/>
</Line>
{period !== 'year' && maximumDiscount > 0 && (
<HotMessage
title={
<Trans>Up to {maximumDiscount.toFixed(0)}% discount</Trans>
}
message={
<Trans>
Get a yearly subscription and enjoy discounts up to
{maximumDiscount.toFixed(0)}%!
</Trans>
}
onClickRightButton={() => setPeriod('year')}
rightButtonLabel={
isMobile ? (
<Trans>Check out</Trans>
) : (
<Trans>See yearly subs</Trans>
)
}
/>
)}
{willCancelAtPeriodEnd && (
<AlertMessage kind="warning">
<Trans>
@@ -733,7 +729,7 @@ export default function SubscriptionDialog({
if (pricingSystem) {
actions = [
<RaisedButton
primary
color="premium"
key="upgrade"
disabled={isLoading}
fullWidth
@@ -772,23 +768,8 @@ export default function SubscriptionDialog({
) : (
<PlaceholderLoader />
)}
{getPlanSpecificRequirements(
i18n,
displayedSubscriptionPlanWithPricingSystems
).map(planSpecificRequirements => (
<AlertMessage
kind="info"
key={planSpecificRequirements.substring(0, 25)}
>
{planSpecificRequirements}
</AlertMessage>
))}
<EmptyMessage>
<Trans>
No ties, cancel your subscription anytime. Payments done using
Stripe.com and PayPal secure infrastructure.
</Trans>
<div>
<Line noMargin justifyContent="center">
<Text size="body" color="secondary">
<Trans>
Compare all the advantages of the different plans in this{' '}
<Link
@@ -803,8 +784,31 @@ export default function SubscriptionDialog({
</Link>
.
</Trans>
</div>
</EmptyMessage>
</Text>
</Line>
<Column noMargin>
<Text size="sub-title">
<Trans>Support What You Love</Trans>
</Text>
<Text size="body" color="secondary">
The GDevelop project is open-source, powered by passion and
community. Your membership helps the GDevelop company maintain
servers, build new features, develop commercial offerings and
keep the open-source project thriving. Our goal: make game
development fast, fun and accessible to all.
</Text>
</Column>
{getPlanSpecificRequirements(
i18n,
displayedSubscriptionPlanWithPricingSystems
).map(planSpecificRequirements => (
<AlertMessage
kind="info"
key={planSpecificRequirements.substring(0, 25)}
>
{planSpecificRequirements}
</AlertMessage>
))}
</ColumnStackLayout>
</Dialog>
{hasMobileAppStoreSubscriptionPlan(

View File

@@ -53,6 +53,7 @@ const EmptyProjectTile = ({
onSelectEmptyProject();
}
}}
id="empty-project-tile"
>
<Column alignItems="center" justifyContent="center" expand>
<Add />

View File

@@ -1,8 +1,9 @@
// @flow
import * as React from 'react';
import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext';
import { type FileMetadata, type SaveAsLocation } from '..';
import { type FileMetadata, type SaveAsLocation, type SaveAsOptions } from '..';
import {
CLOUD_PROJECT_NAME_MAX_LENGTH,
commitVersion,
createCloudProject,
getCredentialsForCloudProject,
@@ -11,7 +12,6 @@ import {
import type { $AxiosError } from 'axios';
import type { MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow';
import { serializeToJSON } from '../../Utils/Serializer';
import CloudSaveAsDialog from './CloudSaveAsDialog';
import { t } from '@lingui/macro';
import {
createZipWithSingleTextFile,
@@ -21,6 +21,7 @@ import ProjectCache from '../../Utils/ProjectCache';
import { getProjectCache } from './CloudProjectOpener';
import { retryIfFailed } from '../../Utils/RetryIfFailed';
import { extractGDevelopApiErrorStatusAndCode } from '../../Utils/GDevelopServices/Errors';
import SaveAsOptionsDialog from '../SaveAsOptionsDialog';
const zipProject = async (project: gdProject): Promise<[Blob, string]> => {
const projectJson = serializeToJSON(project);
@@ -184,38 +185,49 @@ export const generateOnChooseSaveProjectAsLocation = ({
|}) => async ({
project,
fileMetadata,
displayOptionToGenerateNewProjectUuid,
}: {|
project: gdProject,
fileMetadata: ?FileMetadata,
displayOptionToGenerateNewProjectUuid: boolean,
|}): Promise<{|
saveAsLocation: ?SaveAsLocation,
saveAsOptions: ?SaveAsOptions,
|}> => {
if (!authenticatedUser.authenticated) {
return { saveAsLocation: null };
return { saveAsLocation: null, saveAsOptions: null };
}
const name = await new Promise(resolve => {
const options = await new Promise(resolve => {
setDialog(() => (
<CloudSaveAsDialog
<SaveAsOptionsDialog
onCancel={() => {
closeDialog();
resolve(null);
}}
nameSuggestion={project.getName()}
onSave={(newName: string) => {
nameMaxLength={CLOUD_PROJECT_NAME_MAX_LENGTH}
nameSuggestion={
fileMetadata ? `${project.getName()} - Copy` : project.getName()
}
displayOptionToGenerateNewProjectUuid={
displayOptionToGenerateNewProjectUuid
}
onSave={options => {
closeDialog();
resolve(newName);
resolve(options);
}}
/>
));
});
if (!name) return { saveAsLocation: null }; // Save was cancelled.
if (!options) return { saveAsLocation: null, saveAsOptions: null }; // Save was cancelled.
return {
saveAsLocation: {
name,
gameId: project.getProjectUuid(),
name: options.name,
},
saveAsOptions: {
generateNewProjectUuid: options.generateNewProjectUuid,
},
};
};
@@ -243,7 +255,7 @@ export const generateOnSaveProjectAs = (
}
options.onStartSaving();
const gameId = saveAsLocation.gameId || project.getProjectUuid();
const gameId = project.getProjectUuid();
try {
// Create a new cloud project.

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