From 3846a1553be4fa41f8084cad0aba0f709e28b2a7 Mon Sep 17 00:00:00 2001 From: Florian Rival Date: Tue, 11 Nov 2025 18:22:37 +0100 Subject: [PATCH] Experimental in-game editor (#7974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Davy Hélard Don't show in changelog --- .../Builtin/BaseObjectExtension.cpp | 25 + .../Extensions/Metadata/BehaviorMetadata.h | 19 + .../Metadata/InGameEditorResourceMetadata.h | 48 + .../Extensions/Metadata/ObjectMetadata.h | 16 +- Core/GDCore/Extensions/PlatformExtension.cpp | 7 + Core/GDCore/Extensions/PlatformExtension.h | 19 + .../IDE/Events/BehaviorDefaultFlagClearer.cpp | 9 + .../IDE/Events/BehaviorDefaultFlagClearer.h | 6 +- .../IDE/Events/UsedExtensionsFinder.cpp | 26 +- Core/GDCore/IDE/Events/UsedExtensionsFinder.h | 11 +- .../IDE/Events/UsedObjectTypeFinder.cpp | 23 + Core/GDCore/IDE/Events/UsedObjectTypeFinder.h | 39 + .../IDE/Project/ProjectResourcesCopier.cpp | 28 +- .../IDE/Project/ProjectResourcesCopier.h | 10 +- .../Project/ResourcesAbsolutePathChecker.cpp | 17 - .../Project/ResourcesAbsolutePathChecker.h | 48 - .../IDE/Project/ResourcesMergingHelper.cpp | 8 + .../IDE/Project/ResourcesMergingHelper.h | 19 +- .../IDE/Project/SceneResourcesFinder.cpp | 9 + .../GDCore/IDE/Project/SceneResourcesFinder.h | 10 +- Core/GDCore/IDE/ProjectBrowserHelper.cpp | 6 + Core/GDCore/IDE/ProjectBrowserHelper.h | 12 + Core/GDCore/IDE/ResourceExposer.cpp | 28 + Core/GDCore/IDE/ResourceExposer.h | 13 +- Core/GDCore/Project/InitialInstance.cpp | 84 +- Core/GDCore/Project/InitialInstance.h | 16 +- Core/GDCore/Project/Layer.cpp | 5 + Core/GDCore/Project/Layer.h | 3 + Core/GDCore/Project/Object.cpp | 2 + Core/GDCore/Project/Project.cpp | 10 + Core/GDCore/Project/Project.h | 17 + Core/GDCore/Project/PropertyDescriptor.cpp | 4 +- Core/GDCore/Project/ResourcesManager.cpp | 16 + Core/GDCore/Project/ResourcesManager.h | 29 +- .../Serialization/SerializerElement.cpp | 14 +- Extensions/3D/A_RuntimeObject3D.ts | 49 +- Extensions/3D/A_RuntimeObject3DRenderer.ts | 2 + Extensions/3D/Base3DBehavior.ts | 16 +- Extensions/3D/CustomRuntimeObject3D.ts | 16 +- .../3D/CustomRuntimeObject3DRenderer.ts | 2 + Extensions/3D/Model3DRuntimeObject.ts | 27 +- .../3D/Model3DRuntimeObject3DRenderer.ts | 12 +- Extensions/AnchorBehavior/Extension.cpp | 1 + .../tests/anchorruntimebehavior.spec.js | 13 + .../BitmapText/bitmaptextruntimeobject.ts | 8 +- .../tests/draggableruntimebehavior.spec.js | 13 + Extensions/Leaderboards/leaderboardstools.ts | 9 + .../Lighting/InGameEditor/LightIcon.png | Bin 0 -> 716 bytes Extensions/Lighting/JsExtension.js | 10 +- .../lightruntimeobject-pixi-renderer.ts | 86 +- Extensions/Lighting/lightruntimeobject.ts | 27 +- .../LinkedObjects/tests/linkedobjects.spec.js | 13 + Extensions/Multiplayer/multiplayertools.ts | 9 + .../panelspriteruntimeobject.ts | 20 +- .../particleemitterobject-pixi-renderer.ts | 66 +- .../ParticleSystem/particleemitterobject.ts | 170 +- ...rameRatePathfindingRuntimeBehavior.spec.js | 13 + .../commonpathfindingruntimebehavior.spec.js | 13 + .../legacypathfindingruntimebehavior.spec.js | 13 + .../Physics2Behavior/Box2D_v2.3.1_min.wasm.js | 44 +- .../playerauthenticationtools.ts | 7 + Extensions/PrimitiveDrawing/Extension.cpp | 5 + .../InGameEditor/ShapePainterIcon.png | Bin 0 -> 394 bytes ...shapepainterruntimeobject-pixi-renderer.ts | 25 + .../shapepainterruntimeobject.ts | 8 +- ...meobject.pixiruntimegamewithassets.spec.js | 13 + Extensions/SaveState/tests/SaveState.spec.js | 11 + .../managers/pixi-spine-atlas-manager.ts | 8 +- Extensions/Spine/spineruntimeobject.ts | 16 +- ...meobject.pixiruntimegamewithassets.spec.js | 13 + .../TextInput/textinputruntimeobject.ts | 15 +- Extensions/TextObject/textruntimeobject.ts | 12 +- .../TileMap/simpletilemapruntimeobject.ts | 16 +- .../tilemapcollisionmaskruntimeobject.spec.js | 13 + Extensions/TileMap/tilemapruntimeobject.ts | 16 +- .../tiledspriteruntimeobject.ts | 20 +- .../tests/topdownmovementbehavior.spec.js | 13 + Extensions/Video/videoruntimeobject.ts | 8 +- GDJS/GDJS/IDE/Exporter.cpp | 49 +- GDJS/GDJS/IDE/Exporter.h | 31 +- GDJS/GDJS/IDE/ExporterHelper.cpp | 595 +- GDJS/GDJS/IDE/ExporterHelper.h | 204 +- GDJS/Runtime/CustomRuntimeObject.ts | 90 +- .../CustomRuntimeObjectInstanceContainer.ts | 72 +- GDJS/Runtime/InGameEditor/FocusIcon.svg | 7 + GDJS/Runtime/InGameEditor/FreeCameraIcon.svg | 14 + GDJS/Runtime/InGameEditor/InGameEditor.tsx | 4998 +++++++++++++++++ GDJS/Runtime/InGameEditor/MoveIcon.svg | 7 + GDJS/Runtime/InGameEditor/OrbitCameraIcon.svg | 22 + GDJS/Runtime/InGameEditor/ResizeIcon.svg | 5 + GDJS/Runtime/InGameEditor/RotateIcon.svg | 4 + GDJS/Runtime/Model3DManager.ts | 9 +- GDJS/Runtime/ResourceLoader.ts | 66 + GDJS/Runtime/RuntimeInstanceContainer.ts | 41 +- GDJS/Runtime/RuntimeLayer.ts | 16 +- .../debugger-client/InGameDebugger.tsx | 5 +- .../abstract-debugger-client.ts | 521 +- GDJS/Runtime/debugger-client/hot-reloader.ts | 422 +- .../websocket-debugger-client.ts | 13 + .../window-message-debugger-client.ts | 6 + .../fontfaceobserver-font-manager.ts | 2 +- .../howler-sound-manager.ts | 26 +- GDJS/Runtime/inputmanager.ts | 92 +- GDJS/Runtime/jsonmanager.ts | 4 +- GDJS/Runtime/pixi-renderers/ThreeAddons.js | 3 +- .../pixi-renderers/layer-pixi-renderer.ts | 521 +- .../pixi-renderers/pixi-bitmapfont-manager.ts | 2 +- .../pixi-renderers/pixi-image-manager.ts | 4 + .../runtimegame-pixi-renderer.ts | 64 +- .../runtimescene-pixi-renderer.ts | 5 +- .../spriteruntimeobject-pixi-renderer.ts | 77 +- GDJS/Runtime/runtimebehavior.ts | 6 +- GDJS/Runtime/runtimegame.ts | 328 +- GDJS/Runtime/runtimeobject.ts | 16 + GDJS/Runtime/runtimescene.ts | 62 +- GDJS/Runtime/scenestack.ts | 10 +- GDJS/Runtime/spriteruntimeobject.ts | 62 +- GDJS/Runtime/types/global-three-addons.d.ts | 6 + GDJS/Runtime/types/project-data.d.ts | 34 +- GDJS/scripts/lib/runtime-files-list.js | 4 +- GDJS/tests/benchmarks/runtimeobject.js | 9 +- .../init.pixiruntimegamewithassets.js | 14 + GDJS/tests/tests/ResourceLoader.js | 13 + GDJS/tests/tests/common.js | 15 +- GDJS/tests/tests/effects.js | 13 + GDJS/tests/tests/hot-reloader.js | 27 + GDJS/tests/tests/runtimescene.js | 13 + GDJS/tests/tests/scenestack.js | 14 + GDevelop.js/Bindings/Bindings.idl | 56 +- GDevelop.js/Bindings/Wrapper.cpp | 4 + GDevelop.js/__tests__/Core.js | 7 +- GDevelop.js/scripts/generate-types.js | 8 + GDevelop.js/types.d.ts | 40 +- .../types/gdbehaviordefaultflagclearer.js | 6 + GDevelop.js/types/gdbehaviormetadata.js | 2 + GDevelop.js/types/gdexporter.js | 2 + .../types/gdingameeditorresourcemetadata.js | 8 + GDevelop.js/types/gdinitialinstance.js | 6 + GDevelop.js/types/gdlayer.js | 2 + GDevelop.js/types/gdobjectmetadata.js | 1 + GDevelop.js/types/gdpreviewexportoptions.js | 11 +- GDevelop.js/types/gdproject.js | 2 + GDevelop.js/types/gdusedobjecttypefinder.js | 6 + GDevelop.js/types/libgdevelop.js | 3 + .../jsm/controls/TransformControls.js | 1573 ++++++ .../examples/jsm/interactive/SelectionBox.js | 227 + .../jsm/postprocessing/OutlinePass.js | 654 +++ SharedLibs/ThreeAddons/src/index.ts | 6 + .../src/AiGeneration/AskAiEditorContainer.js | 16 + .../src/AssetStore/AssetPackInstallDialog.js | 13 +- newIDE/app/src/AssetStore/InstallAsset.js | 5 + newIDE/app/src/AssetStore/NewObjectDialog.js | 21 +- .../app/src/CommandPalette/CommandManager.js | 17 +- newIDE/app/src/CommandPalette/CommandsList.js | 5 + .../app/src/CompactPropertiesEditor/index.js | 2 +- newIDE/app/src/Debugger/DebuggerSelector.js | 59 +- newIDE/app/src/Debugger/index.js | 76 +- newIDE/app/src/EditorFunctions/index.js | 12 + newIDE/app/src/EffectsList/index.js | 30 +- .../app/src/EmbeddedGame/EmbeddedGameFrame.js | 685 +++ .../EmbeddedGame/EmbeddedGameFrame.module.css | 25 + .../src/EmbeddedGame/EmbeddedGameFrameHole.js | 93 + .../src/EmbeddedGame/InGameEditorSettings.js | 48 + .../EventsFunctionsExtensionEditor/index.js | 5 + .../EventsBasedObjectTreeViewItemContent.js | 2 + newIDE/app/src/EventsFunctionsList/index.js | 6 + .../BrowserExporters/BrowserFileSystem.js | 4 + .../BrowserPreviewDebuggerServer.js | 180 +- .../BrowserExporters/BrowserS3FileSystem.js | 4 + .../BrowserS3PreviewLauncher/index.js | 48 +- .../BrowserExporters/BrowserSWFileSystem.js | 4 + .../BrowserSWPreviewLauncher/index.js | 242 +- .../LocalExporters/LocalFileSystem.js | 4 + .../LocalPreviewDebuggerServer.js | 99 +- .../LocalPreviewLauncher/index.js | 402 +- .../ExportAndShare/PreviewLauncher.flow.js | 64 +- .../LocalGDJSDevelopmentWatcher.js | 36 +- .../app/src/HotReload/HotReloadLogsDialog.js | 34 +- .../app/src/InstancesEditor/InstancesAdder.js | 112 +- .../InstancesEditorSettings.js | 4 + .../InstancesEditor/InstancesList/index.js | 10 +- .../InstancesRenderer/LayerRenderer.js | 2 +- .../src/InstancesEditor/InstancesSelection.js | 2 +- newIDE/app/src/InstancesEditor/index.js | 23 +- .../src/KeyboardShortcuts/DefaultShortcuts.js | 12 +- .../KeyboardShortcuts.spec.js | 4 +- newIDE/app/src/KeyboardShortcuts/index.js | 197 +- .../app/src/LayersList/LayerEditorDialog.js | 53 +- newIDE/app/src/LayersList/LayerRow.js | 8 +- newIDE/app/src/LayersList/index.js | 79 +- newIDE/app/src/LocalApp.js | 2 +- .../MainFrame/EditorContainers/BaseEditor.js | 24 +- .../CustomObjectEditorContainer.js | 95 +- .../DebuggerEditorContainer.js | 10 + .../EditorContainers/EventsEditorContainer.js | 24 +- ...EventsFunctionsExtensionEditorContainer.js | 32 +- .../ExternalEventsEditorContainer.js | 10 + .../ExternalLayoutEditorContainer.js | 136 +- .../EditorContainers/HomePage/index.js | 77 +- .../ResourcesEditorContainer.js | 10 + .../EditorContainers/SceneEditorContainer.js | 74 +- .../MainFrame/EditorTabs/EditorTabsHandler.js | 61 +- .../EditorTabs/UseEditorTabsStateSaving.js | 6 +- newIDE/app/src/MainFrame/EditorTabsPane.js | 80 +- newIDE/app/src/MainFrame/MainFrameCommands.js | 5 + .../PanesContainer/PanesContainer.module.css | 13 +- .../app/src/MainFrame/PanesContainer/index.js | 2 + .../Preferences/PreferencesContext.js | 4 + .../Preferences/PreferencesDialog.js | 57 +- .../Preferences/PreferencesProvider.js | 13 + newIDE/app/src/MainFrame/PreviewState.js | 120 +- newIDE/app/src/MainFrame/TabsTitlebar.js | 2 +- .../app/src/MainFrame/TabsTitlebarTooltip.js | 20 +- .../Toolbar/PreviewAndShareButtons.js | 10 +- newIDE/app/src/MainFrame/index.js | 1018 +++- .../CompactObjectPropertiesEditor/index.js | 21 +- .../CustomObjectPropertiesEditor/index.js | 3 + .../Editors/SpriteEditor/AnimationList.js | 11 +- .../CollisionMasksEditor/index.js | 2 +- .../SpriteEditor/PointsEditor/index.js | 2 +- .../Editors/SpriteEditor/SpritesList.js | 11 +- .../src/ObjectEditor/ObjectEditorDialog.js | 30 +- .../ObjectFolderTreeViewItemContent.js | 21 + .../ObjectsList/ObjectTreeViewItemContent.js | 33 + newIDE/app/src/ObjectsList/index.js | 47 +- .../PlatformSpecificAssets.js | 1 + newIDE/app/src/ProjectManager/index.js | 22 +- .../ResourcePropertiesEditor/index.js | 1 + .../BrowserResourceExternalEditors.js | 7 + .../index.js | 32 +- .../LocalResourceExternalEditors.js | 7 + .../app/src/ResourcesList/ResourceSelector.js | 39 +- .../app/src/ResourcesList/ResourceSource.js | 2 + .../ExtractAsCustomObjectDialog.js | 2 +- .../src/SceneEditor/EditorsDisplay.flow.js | 20 +- ...stanceOrObjectPropertiesEditorContainer.js | 3 + .../MosaicEditorsDisplay/Toolbar.js | 39 +- .../SceneEditor/MosaicEditorsDisplay/index.js | 137 +- .../src/SceneEditor/ScenePropertiesDialog.js | 10 + newIDE/app/src/SceneEditor/SetupGridDialog.js | 26 + .../SwipeableDrawerEditorsDisplay/Toolbar.js | 37 +- .../SwipeableDrawerEditorsDisplay/index.js | 133 +- newIDE/app/src/SceneEditor/index.js | 950 +++- newIDE/app/src/UI/ClosableTabs.js | 2 +- .../CompactToggleButtons.module.css | 21 +- .../app/src/UI/CompactToggleButtons/index.js | 16 +- newIDE/app/src/UI/CustomSvgIcons/Grid2d.js | 13 + newIDE/app/src/UI/CustomSvgIcons/Grid3d.js | 13 + newIDE/app/src/UI/Dialog.js | 42 + .../app/src/UI/DragAndDrop/CustomDragLayer.js | 19 + .../DragAndDrop/DragAndDropContextProvider.js | 11 +- .../UI/DragAndDrop/DragSourceAndDropTarget.js | 1 + newIDE/app/src/UI/EditorMosaic/index.js | 67 +- newIDE/app/src/UI/EditorMosaic/style.css | 20 +- newIDE/app/src/UI/LoaderModal.js | 36 +- newIDE/app/src/UI/PlaceholderMessage.js | 1 + newIDE/app/src/UI/Theme/Global/Mosaic.css | 24 +- .../app/src/UI/Theme/RosePineTheme/theme.json | 6 +- .../UI/Theme/SolarizedDarkTheme/theme.json | 6 +- newIDE/app/src/UI/Toolbar.js | 1 + newIDE/app/src/UI/TreeView/TreeViewRow.js | 6 + newIDE/app/src/Utils/Layout.js | 24 + newIDE/app/src/Utils/Serializer.js | 40 +- .../src/Utils/UseExtensionLoadErrorDialog.js | 2 +- newIDE/app/src/Utils/ZoomUtils.js | 1 + .../app/src/stories/FakeResourceManagement.js | 2 + .../AssetPackInstallDialog.stories.js | 1 + .../componentStories/ClosableTabs.stories.js | 1 + .../EventsFunctionsExtensionEditor.stories.js | 4 + .../EventsFunctionsList.stories.js | 1 + ...zeInstancesEditorWithScrollbars.stories.js | 3 + .../HomePage/HomePage.stories.js | 1 + .../LayoutEditor/LayersList.stories.js | 10 + .../LayoutEditor/ObjectsList.stories.js | 3 + .../ScenePropertiesDialog.stories.js | 2 + .../CustomObjectEditor.stories.js | 1 + .../ObjectEditor/EffectsList.stories.js | 13 + .../ObjectEditor/PanelSpriteEditor.stories.js | 1 + .../ParticleEmitterEditor.stories.js | 1 + .../ShapePainterEditor.stories.js | 1 + .../ObjectEditor/TextEditor.stories.js | 1 + .../ObjectEditor/TiledSpriteEditor.stories.js | 1 + .../ProjectManager/ProjectManager.stories.js | 4 + ...ctResourceSelectorWithThumbnail.stories.js | 2 + .../ResourcesList/ResourceSelector.stories.js | 2 + .../UI/CompactToggleButtons.stories.js | 42 + newIDE/electron-app/app/DebuggerServer.js | 39 +- .../electron-app/app/DiscordRichPresence.js | 10 +- .../app/LocalGDJSDevelopmentWatcher.js | 8 + newIDE/electron-app/app/PreviewWindow.js | 13 + newIDE/electron-app/app/ServeFolder.js | 2 +- newIDE/electron-app/app/main.js | 29 +- 292 files changed, 17504 insertions(+), 2161 deletions(-) create mode 100644 Core/GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h create mode 100644 Core/GDCore/IDE/Events/UsedObjectTypeFinder.cpp create mode 100644 Core/GDCore/IDE/Events/UsedObjectTypeFinder.h delete mode 100644 Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.cpp delete mode 100644 Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.h create mode 100644 Extensions/Lighting/InGameEditor/LightIcon.png create mode 100644 Extensions/PrimitiveDrawing/InGameEditor/ShapePainterIcon.png create mode 100644 GDJS/Runtime/InGameEditor/FocusIcon.svg create mode 100644 GDJS/Runtime/InGameEditor/FreeCameraIcon.svg create mode 100644 GDJS/Runtime/InGameEditor/InGameEditor.tsx create mode 100644 GDJS/Runtime/InGameEditor/MoveIcon.svg create mode 100644 GDJS/Runtime/InGameEditor/OrbitCameraIcon.svg create mode 100644 GDJS/Runtime/InGameEditor/ResizeIcon.svg create mode 100644 GDJS/Runtime/InGameEditor/RotateIcon.svg create mode 100644 GDevelop.js/types/gdbehaviordefaultflagclearer.js create mode 100644 GDevelop.js/types/gdingameeditorresourcemetadata.js create mode 100644 GDevelop.js/types/gdusedobjecttypefinder.js create mode 100644 SharedLibs/ThreeAddons/src/examples/jsm/controls/TransformControls.js create mode 100644 SharedLibs/ThreeAddons/src/examples/jsm/interactive/SelectionBox.js create mode 100644 SharedLibs/ThreeAddons/src/examples/jsm/postprocessing/OutlinePass.js create mode 100644 newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js create mode 100644 newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.module.css create mode 100644 newIDE/app/src/EmbeddedGame/EmbeddedGameFrameHole.js create mode 100644 newIDE/app/src/EmbeddedGame/InGameEditorSettings.js create mode 100644 newIDE/app/src/UI/CustomSvgIcons/Grid2d.js create mode 100644 newIDE/app/src/UI/CustomSvgIcons/Grid3d.js diff --git a/Core/GDCore/Extensions/Builtin/BaseObjectExtension.cpp b/Core/GDCore/Extensions/Builtin/BaseObjectExtension.cpp index c7d66c8db8..295dbbdc02 100644 --- a/Core/GDCore/Extensions/Builtin/BaseObjectExtension.cpp +++ b/Core/GDCore/Extensions/Builtin/BaseObjectExtension.cpp @@ -40,6 +40,31 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsBaseObjectExtension( extension.AddInstructionOrExpressionGroupMetadata(_("Size")).SetIcon( "res/actions/scale24_black.png"); + extension.AddInGameEditorResource() + .SetResourceName("InGameEditor-MoveIcon") + .SetFilePath("InGameEditor/MoveIcon.svg") + .SetKind("internal-in-game-editor-only-svg"); + extension.AddInGameEditorResource() + .SetResourceName("InGameEditor-RotateIcon") + .SetFilePath("InGameEditor/RotateIcon.svg") + .SetKind("internal-in-game-editor-only-svg"); + extension.AddInGameEditorResource() + .SetResourceName("InGameEditor-ResizeIcon") + .SetFilePath("InGameEditor/ResizeIcon.svg") + .SetKind("internal-in-game-editor-only-svg"); + extension.AddInGameEditorResource() + .SetResourceName("InGameEditor-FocusIcon") + .SetFilePath("InGameEditor/FocusIcon.svg") + .SetKind("internal-in-game-editor-only-svg"); + extension.AddInGameEditorResource() + .SetResourceName("InGameEditor-FreeCameraIcon") + .SetFilePath("InGameEditor/FreeCameraIcon.svg") + .SetKind("internal-in-game-editor-only-svg"); + extension.AddInGameEditorResource() + .SetResourceName("InGameEditor-OrbitCameraIcon") + .SetFilePath("InGameEditor/OrbitCameraIcon.svg") + .SetKind("internal-in-game-editor-only-svg"); + gd::ObjectMetadata& obj = extension.AddObject( "", _("Base object"), _("Base object"), "res/objeticon24.png"); diff --git a/Core/GDCore/Extensions/Metadata/BehaviorMetadata.h b/Core/GDCore/Extensions/Metadata/BehaviorMetadata.h index fc732ea6e7..d3bec749fd 100644 --- a/Core/GDCore/Extensions/Metadata/BehaviorMetadata.h +++ b/Core/GDCore/Extensions/Metadata/BehaviorMetadata.h @@ -311,6 +311,24 @@ class GD_CORE_API BehaviorMetadata : public InstructionOrExpressionContainerMeta return *this; } + /** + * Check if the behavior should be run by the editor when it is attached + * to a child-object. + * + * When variants are edited behaviors attached to their direct child-objects + * are not executed. + */ + bool IsActivatedByDefaultInEditor() const { return isActivatedByDefaultInEditor; } + + /** + * Set that behavior should be run by the editor when it is attached to a + * child-object. + */ + BehaviorMetadata &MarkAsActivatedByDefaultInEditor() { + isActivatedByDefaultInEditor = true; + return *this; + } + QuickCustomization::Visibility GetQuickCustomizationVisibility() const { return quickCustomizationVisibility; } @@ -407,6 +425,7 @@ class GD_CORE_API BehaviorMetadata : public InstructionOrExpressionContainerMeta bool isPrivate = false; bool isHidden = false; bool isRelevantForChildObjects = true; + bool isActivatedByDefaultInEditor = false; gd::String openFullEditorLabel; QuickCustomization::Visibility quickCustomizationVisibility = QuickCustomization::Visibility::Default; diff --git a/Core/GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h b/Core/GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h new file mode 100644 index 0000000000..66957496f2 --- /dev/null +++ b/Core/GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h @@ -0,0 +1,48 @@ +/* + * GDevelop Core + * Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ +#pragma once +#include "GDCore/String.h" +#include "GDCore/Serialization/SerializerElement.h" + +namespace gd { +/** + * \brief Describe a resource to be used in the in-game editor. + */ +class GD_CORE_API InGameEditorResourceMetadata { + public: + InGameEditorResourceMetadata() {}; + + InGameEditorResourceMetadata& SetResourceName(const gd::String& resourceName_) { + resourceName = resourceName_; + return *this; + }; + + /** + * Set the file path relative to the Runtime folder. + * In-game editor resources are not copied during a preview. + * Instead, they are included in the exported project with an absolute path. + */ + InGameEditorResourceMetadata& SetFilePath(const gd::String& relativeFilePath_) { + relativeFilePath = relativeFilePath_; + return *this; + }; + + InGameEditorResourceMetadata& SetKind(const gd::String& kind_) { + kind = kind_; + return *this; + }; + + const gd::String& GetResourceName() const { return resourceName; }; + const gd::String& GetFilePath() const { return relativeFilePath; }; + const gd::String& GetKind() const { return kind; }; + + private: + gd::String resourceName; + gd::String relativeFilePath; + gd::String kind; +}; + +} // namespace gd \ No newline at end of file diff --git a/Core/GDCore/Extensions/Metadata/ObjectMetadata.h b/Core/GDCore/Extensions/Metadata/ObjectMetadata.h index cb06ff47c9..babdaaff37 100644 --- a/Core/GDCore/Extensions/Metadata/ObjectMetadata.h +++ b/Core/GDCore/Extensions/Metadata/ObjectMetadata.h @@ -14,6 +14,7 @@ #include "GDCore/Extensions/Metadata/ExpressionMetadata.h" #include "GDCore/Extensions/Metadata/InstructionMetadata.h" +#include "GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h" #include "GDCore/Project/Object.h" #include "GDCore/Project/ObjectConfiguration.h" #include "GDCore/String.h" @@ -305,7 +306,6 @@ class GD_CORE_API ObjectMetadata : public InstructionOrExpressionContainerMetada */ std::map& GetAllStrExpressions() override { return strExpressionsInfos; }; - /** * Check if the behavior is private - it can't be used outside of its * extension. @@ -358,6 +358,19 @@ class GD_CORE_API ObjectMetadata : public InstructionOrExpressionContainerMetada return openFullEditorLabel; } + /** + * \brief Declare a new resource to be used in the in-game editor. + */ + InGameEditorResourceMetadata& AddInGameEditorResource() { + InGameEditorResourceMetadata newInGameEditorResource; + inGameEditorResources.push_back(newInGameEditorResource); + return inGameEditorResources.back(); + } + + const std::vector& GetInGameEditorResources() const { + return inGameEditorResources; + } + std::map conditionsInfos; std::map actionsInfos; std::map expressionsInfos; @@ -381,6 +394,7 @@ class GD_CORE_API ObjectMetadata : public InstructionOrExpressionContainerMetada bool hidden = false; bool isRenderedIn3D = false; gd::String openFullEditorLabel; + std::vector inGameEditorResources; std::shared_ptr blueprintObject; ///< The "blueprint" object to be copied when a new diff --git a/Core/GDCore/Extensions/PlatformExtension.cpp b/Core/GDCore/Extensions/PlatformExtension.cpp index c3f5716da3..66d58a7b74 100644 --- a/Core/GDCore/Extensions/PlatformExtension.cpp +++ b/Core/GDCore/Extensions/PlatformExtension.cpp @@ -813,6 +813,13 @@ gd::String PlatformExtension::GetObjectFullType(const gd::String& extensionName, return extensionName + separator + objectName; } +gd::String PlatformExtension::GetVariantFullType(const gd::String& extensionName, + const gd::String& objectName, + const gd::String& variantName) { + const auto& separator = GetNamespaceSeparator(); + return extensionName + separator + objectName + separator + variantName; +} + gd::String PlatformExtension::GetExtensionFromFullObjectType( const gd::String& type) { const auto separatorIndex = diff --git a/Core/GDCore/Extensions/PlatformExtension.h b/Core/GDCore/Extensions/PlatformExtension.h index 41dcb89173..466175405c 100644 --- a/Core/GDCore/Extensions/PlatformExtension.h +++ b/Core/GDCore/Extensions/PlatformExtension.h @@ -361,6 +361,16 @@ class GD_CORE_API PlatformExtension { * generator. */ void StripUnimplementedInstructionsAndExpressions(); + + + /** + * \brief Declare a new resource to be used in the in-game editor. + */ + InGameEditorResourceMetadata& AddInGameEditorResource() { + InGameEditorResourceMetadata newInGameEditorResource; + inGameEditorResources.push_back(newInGameEditorResource); + return inGameEditorResources.back(); + } ///@} /** \name Extension accessors @@ -632,6 +642,10 @@ class GD_CORE_API PlatformExtension { GetAllInstructionOrExpressionGroupMetadata() const { return instructionOrExpressionGroupMetadata; } + + const std::vector& GetInGameEditorResources() const { + return inGameEditorResources; + } ///@} /** @@ -663,6 +677,10 @@ class GD_CORE_API PlatformExtension { static gd::String GetObjectFullType(const gd::String& extensionName, const gd::String& objectName); +static gd::String GetVariantFullType(const gd::String& extensionName, + const gd::String& objectName, + const gd::String& variantName); + static gd::String GetExtensionFromFullObjectType(const gd::String& type); static gd::String GetObjectNameFromFullObjectType(const gd::String& type); @@ -704,6 +722,7 @@ class GD_CORE_API PlatformExtension { std::map extensionPropertiesMetadata; std::map instructionOrExpressionGroupMetadata; + std::vector inGameEditorResources; ObjectMetadata badObjectMetadata; BehaviorMetadata badBehaviorMetadata; diff --git a/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.cpp b/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.cpp index c70131d1a1..cc5297040b 100644 --- a/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.cpp +++ b/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.cpp @@ -20,4 +20,13 @@ void BehaviorDefaultFlagClearer::DoVisitBehavior(gd::Behavior& behavior) { BehaviorDefaultFlagClearer::~BehaviorDefaultFlagClearer() {} +void BehaviorDefaultFlagClearer::SerializeObjectWithCleanDefaultBehaviorFlags( + const gd::Object &object, SerializerElement &serializerElement) { + gd::Object clonedObject(object); + for (const auto &behaviorName : clonedObject.GetAllBehaviorNames()) { + clonedObject.GetBehavior(behaviorName).SetDefaultBehavior(false); + } + clonedObject.SerializeTo(serializerElement); +} + } // namespace gd diff --git a/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.h b/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.h index 4a6cc06d7d..8893996f56 100644 --- a/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.h +++ b/Core/GDCore/IDE/Events/BehaviorDefaultFlagClearer.h @@ -13,6 +13,7 @@ namespace gd { class Object; class Behavior; +class SerializerElement; } // namespace gd namespace gd { @@ -26,7 +27,10 @@ class GD_CORE_API BehaviorDefaultFlagClearer : public ArbitraryObjectsWorker { BehaviorDefaultFlagClearer() {}; virtual ~BehaviorDefaultFlagClearer(); - private: + static void SerializeObjectWithCleanDefaultBehaviorFlags( + const gd::Object &object, SerializerElement &serializerElement); + +private: void DoVisitObject(gd::Object& object) override; void DoVisitBehavior(gd::Behavior& behavior) override; }; diff --git a/Core/GDCore/IDE/Events/UsedExtensionsFinder.cpp b/Core/GDCore/IDE/Events/UsedExtensionsFinder.cpp index f66d88fe2c..b814ac8a1a 100644 --- a/Core/GDCore/IDE/Events/UsedExtensionsFinder.cpp +++ b/Core/GDCore/IDE/Events/UsedExtensionsFinder.cpp @@ -4,6 +4,7 @@ #include "GDCore/Extensions/Metadata/MetadataProvider.h" #include "GDCore/Extensions/Metadata/ParameterMetadataTools.h" #include "GDCore/Extensions/PlatformExtension.h" +#include "GDCore/Extensions/Platform.h" #include "GDCore/IDE/ProjectBrowserHelper.h" #include "GDCore/IDE/WholeProjectRefactorer.h" #include "GDCore/IDE/Events/ExpressionTypeFinder.h" @@ -19,14 +20,24 @@ void UsedExtensionsResult::AddUsedExtension(const gd::PlatformExtension& extensi usedSourceFiles.insert(usedSourceFiles.end(), extension.GetAllSourceFiles().begin(), extension.GetAllSourceFiles().end()); + + for (auto &&inGameEditorResource : extension.GetInGameEditorResources()) { + AddUsedInGameEditorResource(inGameEditorResource); + } } -void UsedExtensionsResult::AddUsedBuiltinExtension(const gd::String& extensionName) { - usedExtensions.insert(extensionName); +void UsedExtensionsResult::AddUsedBuiltinExtension(const gd::Project& project, const gd::String& extensionName) { + if (project.GetCurrentPlatform().IsExtensionLoaded(extensionName)) { + auto extension = project.GetCurrentPlatform().GetExtension(extensionName); + if (extension) { + AddUsedExtension(*extension); + } + } } const UsedExtensionsResult UsedExtensionsFinder::ScanProject(gd::Project& project) { UsedExtensionsFinder worker(project); + worker.result.AddUsedBuiltinExtension(project, "BuiltinObject"); gd::ProjectBrowserHelper::ExposeProjectObjects(project, worker); gd::ProjectBrowserHelper::ExposeProjectEvents(project, worker); return worker.result; @@ -44,6 +55,9 @@ void UsedExtensionsFinder::DoVisitObject(gd::Object &object) { for (auto &&includeFile : metadata.GetMetadata().includeFiles) { result.AddUsedIncludeFiles(includeFile); } + for (auto &&inGameEditorResource : metadata.GetMetadata().GetInGameEditorResources()) { + result.AddUsedInGameEditorResource(inGameEditorResource); + } }; // Behaviors scanner @@ -89,7 +103,7 @@ bool UsedExtensionsFinder::DoVisitInstruction(gd::Instruction& instruction, rootType = "number"; parameterValue.GetRootNode()->Visit(*this); } else if (gd::ParameterMetadata::IsExpression("variable", parameterType)) - result.AddUsedBuiltinExtension("BuiltinVariables"); + result.AddUsedBuiltinExtension(project, "BuiltinVariables"); }); return false; @@ -122,7 +136,7 @@ void UsedExtensionsFinder::OnVisitUnaryOperatorNode(UnaryOperatorNode& node) { // Add variable extension and visit sub-expressions on variable nodes void UsedExtensionsFinder::OnVisitVariableNode(VariableNode& node) { - result.AddUsedBuiltinExtension("BuiltinVariables"); + result.AddUsedBuiltinExtension(project, "BuiltinVariables"); auto type = gd::ExpressionTypeFinder::GetType( project.GetCurrentPlatform(), GetProjectScopedContainers(), rootType, node); @@ -155,13 +169,13 @@ void UsedExtensionsFinder::OnVisitVariableNode(VariableNode& node) { void UsedExtensionsFinder::OnVisitVariableAccessorNode( VariableAccessorNode& node) { - result.AddUsedBuiltinExtension("BuiltinVariables"); + result.AddUsedBuiltinExtension(project, "BuiltinVariables"); if (node.child) node.child->Visit(*this); }; void UsedExtensionsFinder::OnVisitVariableBracketAccessorNode( VariableBracketAccessorNode& node) { - result.AddUsedBuiltinExtension("BuiltinVariables"); + result.AddUsedBuiltinExtension(project, "BuiltinVariables"); node.expression->Visit(*this); if (node.child) node.child->Visit(*this); }; diff --git a/Core/GDCore/IDE/Events/UsedExtensionsFinder.h b/Core/GDCore/IDE/Events/UsedExtensionsFinder.h index 2b2928b492..3b0e5a5464 100644 --- a/Core/GDCore/IDE/Events/UsedExtensionsFinder.h +++ b/Core/GDCore/IDE/Events/UsedExtensionsFinder.h @@ -10,6 +10,7 @@ #include "GDCore/Events/Parsers/ExpressionParser2NodeWorker.h" #include "GDCore/Extensions/Metadata/SourceFileMetadata.h" +#include "GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h" #include "GDCore/Extensions/PlatformExtension.h" #include "GDCore/IDE/Events/ArbitraryEventsWorker.h" #include "GDCore/IDE/Project/ArbitraryObjectsWorker.h" @@ -50,6 +51,10 @@ public: return usedSourceFiles; } + const std::vector& GetUsedInGameEditorResources() const { + return usedInGameEditorResources; + } + /** * \brief Return true when at least 1 object uses the 3D renderer. */ @@ -58,9 +63,12 @@ public: } void AddUsedExtension(const gd::PlatformExtension& extension); - void AddUsedBuiltinExtension(const gd::String& extensionName); + void AddUsedBuiltinExtension(const gd::Project& project, const gd::String& extensionName); void AddUsedIncludeFiles(const gd::String& includeFile) { usedIncludeFiles.insert(includeFile); } void AddUsedRequiredFiles(const gd::String& requiredFile) { usedRequiredFiles.insert(requiredFile); } + void AddUsedInGameEditorResource(const gd::InGameEditorResourceMetadata& inGameEditorResource) { + usedInGameEditorResources.push_back(inGameEditorResource); + } void MarkAsHaving3DObjects() { has3DObjects = true; @@ -71,6 +79,7 @@ private: std::set usedIncludeFiles; std::set usedRequiredFiles; std::vector usedSourceFiles; + std::vector usedInGameEditorResources; bool has3DObjects = false; }; diff --git a/Core/GDCore/IDE/Events/UsedObjectTypeFinder.cpp b/Core/GDCore/IDE/Events/UsedObjectTypeFinder.cpp new file mode 100644 index 0000000000..65f0715a44 --- /dev/null +++ b/Core/GDCore/IDE/Events/UsedObjectTypeFinder.cpp @@ -0,0 +1,23 @@ +#include "UsedObjectTypeFinder.h" + +#include "GDCore/Events/Instruction.h" +#include "GDCore/IDE/ProjectBrowserHelper.h" +#include "GDCore/Project/Object.h" +#include "GDCore/Project/Project.h" + +namespace gd { + +bool UsedObjectTypeFinder::ScanProject(gd::Project &project, + const gd::String &objectType) { + UsedObjectTypeFinder worker(project, objectType); + gd::ProjectBrowserHelper::ExposeProjectObjects(project, worker); + return worker.hasFoundObjectType; +}; + +void UsedObjectTypeFinder::DoVisitObject(gd::Object &object) { + if (!hasFoundObjectType && object.GetType() == objectType) { + hasFoundObjectType = true; + } +}; + +} // namespace gd diff --git a/Core/GDCore/IDE/Events/UsedObjectTypeFinder.h b/Core/GDCore/IDE/Events/UsedObjectTypeFinder.h new file mode 100644 index 0000000000..27e83d0f31 --- /dev/null +++ b/Core/GDCore/IDE/Events/UsedObjectTypeFinder.h @@ -0,0 +1,39 @@ +/* + * GDevelop Core + * Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights + * reserved. This project is released under the MIT License. + */ + +#pragma once +#include + +#include "GDCore/Events/Parsers/ExpressionParser2NodeWorker.h" +#include "GDCore/Extensions/Metadata/SourceFileMetadata.h" +#include "GDCore/Extensions/PlatformExtension.h" +#include "GDCore/IDE/Events/ArbitraryEventsWorker.h" +#include "GDCore/IDE/Project/ArbitraryObjectsWorker.h" +#include "GDCore/String.h" + +namespace gd { +class Project; +class Object; +} // namespace gd + +namespace gd { + +class GD_CORE_API UsedObjectTypeFinder : public ArbitraryObjectsWorker { +public: + static bool ScanProject(gd::Project &project, const gd::String &objectType); + +private: + UsedObjectTypeFinder(gd::Project &project_, const gd::String &objectType_) + : project(project_), objectType(objectType_){}; + gd::Project &project; + const gd::String &objectType; + bool hasFoundObjectType = false; + + // Object Visitor + void DoVisitObject(gd::Object &object) override; +}; + +}; // namespace gd diff --git a/Core/GDCore/IDE/Project/ProjectResourcesCopier.cpp b/Core/GDCore/IDE/Project/ProjectResourcesCopier.cpp index dca3d3fed8..75350bbee6 100644 --- a/Core/GDCore/IDE/Project/ProjectResourcesCopier.cpp +++ b/Core/GDCore/IDE/Project/ProjectResourcesCopier.cpp @@ -7,7 +7,6 @@ #include #include "GDCore/CommonTools.h" #include "GDCore/IDE/AbstractFileSystem.h" -#include "GDCore/IDE/Project/ResourcesAbsolutePathChecker.h" #include "GDCore/IDE/Project/ResourcesMergingHelper.h" #include "GDCore/Project/Project.h" #include "GDCore/Tools/Localization.h" @@ -26,42 +25,37 @@ bool ProjectResourcesCopier::CopyAllResourcesTo( bool preserveAbsoluteFilenames, bool preserveDirectoryStructure) { if (updateOriginalProject) { - gd::ProjectResourcesCopier::CopyAllResourcesTo( - originalProject, originalProject, fs, destinationDirectory, - preserveAbsoluteFilenames, preserveDirectoryStructure); + gd::ProjectResourcesCopier::AdaptFilePathsAndCopyAllResourcesTo( + originalProject, fs, destinationDirectory, preserveAbsoluteFilenames, + preserveDirectoryStructure); } else { gd::Project clonedProject = originalProject; - gd::ProjectResourcesCopier::CopyAllResourcesTo( - originalProject, clonedProject, fs, destinationDirectory, - preserveAbsoluteFilenames, preserveDirectoryStructure); + gd::ProjectResourcesCopier::AdaptFilePathsAndCopyAllResourcesTo( + clonedProject, fs, destinationDirectory, preserveAbsoluteFilenames, + preserveDirectoryStructure); } return true; } -bool ProjectResourcesCopier::CopyAllResourcesTo( - gd::Project& originalProject, - gd::Project& clonedProject, +bool ProjectResourcesCopier::AdaptFilePathsAndCopyAllResourcesTo( + gd::Project& project, AbstractFileSystem& fs, gd::String destinationDirectory, bool preserveAbsoluteFilenames, bool preserveDirectoryStructure) { - // Check if there are some resources with absolute filenames - gd::ResourcesAbsolutePathChecker absolutePathChecker(originalProject.GetResourcesManager(), fs); - gd::ResourceExposer::ExposeWholeProjectResources(originalProject, absolutePathChecker); - - auto projectDirectory = fs.DirNameFrom(originalProject.GetProjectFile()); + auto projectDirectory = fs.DirNameFrom(project.GetProjectFile()); std::cout << "Copying all resources from " << projectDirectory << " to " << destinationDirectory << "..." << std::endl; // Get the resources to be copied gd::ResourcesMergingHelper resourcesMergingHelper( - clonedProject.GetResourcesManager(), fs); + project.GetResourcesManager(), fs); resourcesMergingHelper.SetBaseDirectory(projectDirectory); resourcesMergingHelper.PreserveDirectoriesStructure( preserveDirectoryStructure); resourcesMergingHelper.PreserveAbsoluteFilenames(preserveAbsoluteFilenames); - gd::ResourceExposer::ExposeWholeProjectResources(clonedProject, + gd::ResourceExposer::ExposeWholeProjectResources(project, resourcesMergingHelper); // Copy resources diff --git a/Core/GDCore/IDE/Project/ProjectResourcesCopier.h b/Core/GDCore/IDE/Project/ProjectResourcesCopier.h index e1ab25977e..d5ed454cb2 100644 --- a/Core/GDCore/IDE/Project/ProjectResourcesCopier.h +++ b/Core/GDCore/IDE/Project/ProjectResourcesCopier.h @@ -50,12 +50,10 @@ class GD_CORE_API ProjectResourcesCopier { bool preserveDirectoryStructure = true); private: - static bool CopyAllResourcesTo(gd::Project& originalProject, - gd::Project& clonedProject, - gd::AbstractFileSystem& fs, - gd::String destinationDirectory, - bool preserveAbsoluteFilenames = true, - bool preserveDirectoryStructure = true); + static bool AdaptFilePathsAndCopyAllResourcesTo( + gd::Project &project, gd::AbstractFileSystem &fs, + gd::String destinationDirectory, bool preserveAbsoluteFilenames = true, + bool preserveDirectoryStructure = true); }; } // namespace gd diff --git a/Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.cpp b/Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.cpp deleted file mode 100644 index 94f4c0746d..0000000000 --- a/Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.cpp +++ /dev/null @@ -1,17 +0,0 @@ -/* - * GDevelop Core - * Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights - * reserved. This project is released under the MIT License. - */ - -#include "ResourcesAbsolutePathChecker.h" -#include "GDCore/IDE/AbstractFileSystem.h" -#include "GDCore/String.h" - -namespace gd { - -void ResourcesAbsolutePathChecker::ExposeFile(gd::String& resourceFilename) { - if (fs.IsAbsolute(resourceFilename)) hasAbsoluteFilenames = true; -} - -} // namespace gd diff --git a/Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.h b/Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.h deleted file mode 100644 index 07ee7f8397..0000000000 --- a/Core/GDCore/IDE/Project/ResourcesAbsolutePathChecker.h +++ /dev/null @@ -1,48 +0,0 @@ -/* - * GDevelop Core - * Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights - * reserved. This project is released under the MIT License. - */ -#pragma once - -#include "GDCore/IDE/AbstractFileSystem.h" -#include "GDCore/IDE/Project/ArbitraryResourceWorker.h" -#include "GDCore/String.h" - -namespace gd { - -/** - * \brief Helper used to check if a project has at least a resource with an - * absolute filename. - * - * \see ArbitraryResourceWorker - * - * \ingroup IDE - */ -class GD_CORE_API ResourcesAbsolutePathChecker - : public ArbitraryResourceWorker { -public: - ResourcesAbsolutePathChecker(gd::ResourcesManager &resourcesManager, - AbstractFileSystem &fileSystem) - : ArbitraryResourceWorker(resourcesManager), hasAbsoluteFilenames(false), - fs(fileSystem){}; - virtual ~ResourcesAbsolutePathChecker(){}; - - /** - * Return true if there is at least a resource with an absolute filename. - */ - bool HasResourceWithAbsoluteFilenames() const { - return hasAbsoluteFilenames; - }; - - /** - * Check if there is a resource with an absolute path - */ - virtual void ExposeFile(gd::String& resource); - - private: - bool hasAbsoluteFilenames; - AbstractFileSystem& fs; -}; - -} // namespace gd diff --git a/Core/GDCore/IDE/Project/ResourcesMergingHelper.cpp b/Core/GDCore/IDE/Project/ResourcesMergingHelper.cpp index 25dd9bb18c..a9f522663b 100644 --- a/Core/GDCore/IDE/Project/ResourcesMergingHelper.cpp +++ b/Core/GDCore/IDE/Project/ResourcesMergingHelper.cpp @@ -22,6 +22,14 @@ void ResourcesMergingHelper::ExposeFile(gd::String& resourceFilename) { resourceFullFilename = gd::AbstractFileSystem::NormalizeSeparator( resourceFullFilename); // Protect against \ on Linux. + if (shouldUseOriginalAbsoluteFilenames) { + // There is no need to fill `newFilenames` and `oldFilenames` since the file + // location stays the same. + fs.MakeAbsolute(resourceFullFilename, baseDirectory); + resourceFilename = resourceFullFilename; + return; + } + // In the case of absolute filenames that we don't want to preserve, or // in the case of copying files without preserving relative folders, the new // names will be generated from the filename alone (with collision protection). diff --git a/Core/GDCore/IDE/Project/ResourcesMergingHelper.h b/Core/GDCore/IDE/Project/ResourcesMergingHelper.h index 3891fb6021..94abd3ad4f 100644 --- a/Core/GDCore/IDE/Project/ResourcesMergingHelper.h +++ b/Core/GDCore/IDE/Project/ResourcesMergingHelper.h @@ -3,8 +3,7 @@ * Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights * reserved. This project is released under the MIT License. */ -#ifndef RESOURCESMERGINGHELPER_H -#define RESOURCESMERGINGHELPER_H +#pragma once #include #include @@ -58,6 +57,15 @@ public: preserveAbsoluteFilenames = preserveAbsoluteFilenames_; }; + /** + * \brief Set if the absolute filenames of original files must be used for + * any resource. + */ + void SetShouldUseOriginalAbsoluteFilenames( + bool shouldUseOriginalAbsoluteFilenames_ = true) { + shouldUseOriginalAbsoluteFilenames = shouldUseOriginalAbsoluteFilenames_; + }; + /** * \brief Return a map containing the resources old absolute filename as key, * and the resources new filenames as value. The new filenames are relative to @@ -93,10 +101,13 @@ public: ///< absolute (C:\MyFile.png will not be ///< transformed into a relative filename ///< (MyFile.png). + /** + * Set to true if the absolute filenames of original files must be used for + * any resource. + */ + bool shouldUseOriginalAbsoluteFilenames = false; gd::AbstractFileSystem& fs; ///< The gd::AbstractFileSystem used to manipulate files. }; } // namespace gd - -#endif // RESOURCESMERGINGHELPER_H diff --git a/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp b/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp index c003ba2a10..c0af55670a 100644 --- a/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp +++ b/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp @@ -6,6 +6,7 @@ #include "SceneResourcesFinder.h" #include "GDCore/IDE/ResourceExposer.h" +#include "GDCore/Project/EventsBasedObjectVariant.h" #include "GDCore/Project/Layout.h" #include "GDCore/Project/Project.h" #include "GDCore/Serialization/SerializerElement.h" @@ -27,6 +28,14 @@ std::set SceneResourcesFinder::FindSceneResources(gd::Project &proje return resourceWorker.resourceNames; } +std::set SceneResourcesFinder::FindEventsBasedObjectVariantResources(gd::Project &project, + gd::EventsBasedObjectVariant &variant) { + gd::SceneResourcesFinder resourceWorker(project.GetResourcesManager()); + + gd::ResourceExposer::ExposeEventsBasedObjectVariantResources(project, variant, resourceWorker); + return resourceWorker.resourceNames; +} + void SceneResourcesFinder::AddUsedResource(gd::String &resourceName) { if (resourceName.empty()) { return; diff --git a/Core/GDCore/IDE/Project/SceneResourcesFinder.h b/Core/GDCore/IDE/Project/SceneResourcesFinder.h index d7df886045..b4a74c21d7 100644 --- a/Core/GDCore/IDE/Project/SceneResourcesFinder.h +++ b/Core/GDCore/IDE/Project/SceneResourcesFinder.h @@ -15,6 +15,7 @@ namespace gd { class Project; class Layout; class SerializerElement; +class EventsBasedObjectVariant; } // namespace gd namespace gd { @@ -27,7 +28,7 @@ namespace gd { class SceneResourcesFinder : private gd::ArbitraryResourceWorker { public: /** - * @brief Find resource usages in a given scenes. + * @brief Find resource usages in a given scene. * * It doesn't include resources used globally. */ @@ -41,6 +42,13 @@ public: */ static std::set FindProjectResources(gd::Project &project); + /** + * @brief Find resource usages in a given events-based object variant. + */ + static std::set + FindEventsBasedObjectVariantResources(gd::Project &project, + gd::EventsBasedObjectVariant &variant); + virtual ~SceneResourcesFinder(){}; private: diff --git a/Core/GDCore/IDE/ProjectBrowserHelper.cpp b/Core/GDCore/IDE/ProjectBrowserHelper.cpp index 8a684686ea..d3e13ab318 100644 --- a/Core/GDCore/IDE/ProjectBrowserHelper.cpp +++ b/Core/GDCore/IDE/ProjectBrowserHelper.cpp @@ -332,6 +332,12 @@ void ProjectBrowserHelper::ExposeLayoutObjects(gd::Layout &layout, worker.Launch(layout.GetObjects()); } +void ProjectBrowserHelper::ExposeEventsBasedObjectVariantObjects( + gd::EventsBasedObjectVariant &eventsBasedObjectVariant, + gd::ArbitraryObjectsWorker &worker) { + worker.Launch(eventsBasedObjectVariant.GetObjects()); +} + void ProjectBrowserHelper::ExposeProjectFunctions( gd::Project &project, gd::ArbitraryEventsFunctionsWorker &worker) { diff --git a/Core/GDCore/IDE/ProjectBrowserHelper.h b/Core/GDCore/IDE/ProjectBrowserHelper.h index d8d9c4ec2f..ef6a69e40f 100644 --- a/Core/GDCore/IDE/ProjectBrowserHelper.h +++ b/Core/GDCore/IDE/ProjectBrowserHelper.h @@ -13,6 +13,7 @@ class EventsFunctionsExtension; class EventsFunction; class EventsBasedBehavior; class EventsBasedObject; +class EventsBasedObjectVariant; class ArbitraryEventsWorker; class ArbitraryEventsWorkerWithContext; class ArbitraryEventsFunctionsWorker; @@ -207,6 +208,17 @@ public: static void ExposeLayoutObjects(gd::Layout &layout, gd::ArbitraryObjectsWorker &worker); + /** + * \brief Call the specified worker on all ObjectContainers of the + * events-based object variant. + * + * This should be the preferred way to traverse all the objects of an + * events-based object variant. + */ + static void ExposeEventsBasedObjectVariantObjects( + gd::EventsBasedObjectVariant &eventsBasedObjectVariant, + gd::ArbitraryObjectsWorker &worker); + /** * \brief Call the specified worker on all FunctionsContainers of the project * (global, layouts...) diff --git a/Core/GDCore/IDE/ResourceExposer.cpp b/Core/GDCore/IDE/ResourceExposer.cpp index 43cc9b775d..4c47c1f493 100644 --- a/Core/GDCore/IDE/ResourceExposer.cpp +++ b/Core/GDCore/IDE/ResourceExposer.cpp @@ -116,6 +116,34 @@ void ResourceExposer::ExposeLayoutResources( project, layout, eventWorker); } +void ResourceExposer::ExposeEventsBasedObjectVariantResources( + gd::Project &project, + gd::EventsBasedObjectVariant &eventsBasedObjectVariant, + gd::ArbitraryResourceWorker &worker) { + // Expose object configuration resources + auto objectWorker = gd::GetResourceWorkerOnObjects(project, worker); + gd::ProjectBrowserHelper::ExposeEventsBasedObjectVariantObjects( + eventsBasedObjectVariant, objectWorker); + + // Expose layer effect resources + auto &layers = eventsBasedObjectVariant.GetLayers(); + for (std::size_t layerIndex = 0; layerIndex < layers.GetLayersCount(); + layerIndex++) { + auto &layer = layers.GetLayer(layerIndex); + + auto &effects = layer.GetEffects(); + for (size_t effectIndex = 0; effectIndex < effects.GetEffectsCount(); + effectIndex++) { + auto &effect = effects.GetEffect(effectIndex); + gd::ResourceExposer::ExposeEffectResources(project.GetCurrentPlatform(), + effect, worker); + } + } + // We don't check the events because it would cost too much to do it for every + // variant. Resource usage in events-based object events and their + // dependencies should be rare. +} + void ResourceExposer::ExposeEffectResources( gd::Platform &platform, gd::Effect &effect, diff --git a/Core/GDCore/IDE/ResourceExposer.h b/Core/GDCore/IDE/ResourceExposer.h index 867527b7df..14b65482f9 100644 --- a/Core/GDCore/IDE/ResourceExposer.h +++ b/Core/GDCore/IDE/ResourceExposer.h @@ -9,10 +9,11 @@ namespace gd { class Platform; class Project; class ArbitraryResourceWorker; +class EventsBasedObjectVariant; class EventsFunctionsExtension; class Effect; class Layout; -} // namespace gd +} // namespace gd namespace gd { @@ -20,7 +21,7 @@ namespace gd { * \brief */ class GD_CORE_API ResourceExposer { - public: +public: /** * \brief Called ( e.g. during compilation ) so as to inventory internal * resources, sometimes update their filename or any other work or resources. @@ -50,6 +51,14 @@ class GD_CORE_API ResourceExposer { gd::Layout &layout, gd::ArbitraryResourceWorker &worker); + /** + * @brief Expose the resources used in a given events-based object variant. + */ + static void ExposeEventsBasedObjectVariantResources( + gd::Project &project, + gd::EventsBasedObjectVariant &eventsBasedObjectVariant, + gd::ArbitraryResourceWorker &worker); + /** * @brief Expose the resources used in a given effect. */ diff --git a/Core/GDCore/Project/InitialInstance.cpp b/Core/GDCore/Project/InitialInstance.cpp index 31c45b28fd..3944bcc9e4 100644 --- a/Core/GDCore/Project/InitialInstance.cpp +++ b/Core/GDCore/Project/InitialInstance.cpp @@ -60,6 +60,18 @@ void InitialInstance::UnserializeFrom(const SerializerElement& element) { } else { SetHasCustomDepth(false); } + if (element.HasChild("defaultWidth") || + element.HasAttribute("defaultWidth")) { + defaultWidth = element.GetDoubleAttribute("defaultWidth"); + } + if (element.HasChild("defaultHeight") || + element.HasAttribute("defaultHeight")) { + defaultHeight = element.GetDoubleAttribute("defaultHeight"); + } + if (element.HasChild("defaultDepth") || + element.HasAttribute("defaultDepth")) { + defaultDepth = element.GetDoubleAttribute("defaultDepth"); + } SetZOrder(element.GetIntAttribute("zOrder", 0, "plan")); SetOpacity(element.GetIntAttribute("opacity", 255)); SetLayer(element.GetStringAttribute("layer")); @@ -74,45 +86,51 @@ void InitialInstance::UnserializeFrom(const SerializerElement& element) { if (persistentUuid.empty()) ResetPersistentUuid(); numberProperties.clear(); - const SerializerElement& numberPropertiesElement = - element.GetChild("numberProperties", 0, "floatInfos"); - numberPropertiesElement.ConsiderAsArrayOf("property", "Info"); - for (std::size_t j = 0; j < numberPropertiesElement.GetChildrenCount(); ++j) { - gd::String name = - numberPropertiesElement.GetChild(j).GetStringAttribute("name"); - double value = - numberPropertiesElement.GetChild(j).GetDoubleAttribute("value"); + if (element.HasChild("numberProperties", "floatInfos")) { + const SerializerElement& numberPropertiesElement = + element.GetChild("numberProperties", 0, "floatInfos"); + numberPropertiesElement.ConsiderAsArrayOf("property", "Info"); + for (std::size_t j = 0; j < numberPropertiesElement.GetChildrenCount(); ++j) { + gd::String name = + numberPropertiesElement.GetChild(j).GetStringAttribute("name"); + double value = + numberPropertiesElement.GetChild(j).GetDoubleAttribute("value"); - // Compatibility with GD <= 5.1.164 - if (name == "z") { - SetZ(value); - } else if (name == "rotationX") { - SetRotationX(value); - } else if (name == "rotationY") { - SetRotationY(value); - } else if (name == "depth") { - SetHasCustomDepth(true); - SetCustomDepth(value); - } - // end of compatibility code - else { - numberProperties[name] = value; + // Compatibility with GD <= 5.1.164 + if (name == "z") { + SetZ(value); + } else if (name == "rotationX") { + SetRotationX(value); + } else if (name == "rotationY") { + SetRotationY(value); + } else if (name == "depth") { + SetHasCustomDepth(true); + SetCustomDepth(value); + } + // end of compatibility code + else { + numberProperties[name] = value; + } } } stringProperties.clear(); - const SerializerElement& stringPropElement = - element.GetChild("stringProperties", 0, "stringInfos"); - stringPropElement.ConsiderAsArrayOf("property", "Info"); - for (std::size_t j = 0; j < stringPropElement.GetChildrenCount(); ++j) { - gd::String name = stringPropElement.GetChild(j).GetStringAttribute("name"); - gd::String value = - stringPropElement.GetChild(j).GetStringAttribute("value"); - stringProperties[name] = value; + if (element.HasChild("stringProperties", "stringInfos")) { + const SerializerElement& stringPropElement = + element.GetChild("stringProperties", 0, "stringInfos"); + stringPropElement.ConsiderAsArrayOf("property", "Info"); + for (std::size_t j = 0; j < stringPropElement.GetChildrenCount(); ++j) { + gd::String name = stringPropElement.GetChild(j).GetStringAttribute("name"); + gd::String value = + stringPropElement.GetChild(j).GetStringAttribute("value"); + stringProperties[name] = value; + } } - GetVariables().UnserializeFrom( - element.GetChild("initialVariables", 0, "InitialVariables")); + if (element.HasChild("initialVariables", "InitialVariables")) { + GetVariables().UnserializeFrom( + element.GetChild("initialVariables", 0, "InitialVariables")); + } } void InitialInstance::SerializeTo(SerializerElement& element) const { @@ -133,6 +151,8 @@ void InitialInstance::SerializeTo(SerializerElement& element) const { element.SetAttribute("width", GetCustomWidth()); element.SetAttribute("height", GetCustomHeight()); if (HasCustomDepth()) element.SetAttribute("depth", GetCustomDepth()); + // defaultWidth, defaultHeight and defaultDepth are not serialized + // because they are evaluated by InGameEditor. if (IsLocked()) element.SetAttribute("locked", IsLocked()); if (IsSealed()) element.SetAttribute("sealed", IsSealed()); if (ShouldKeepRatio()) element.SetAttribute("keepRatio", ShouldKeepRatio()); diff --git a/Core/GDCore/Project/InitialInstance.h b/Core/GDCore/Project/InitialInstance.h index 4952cbef3c..831feb0635 100644 --- a/Core/GDCore/Project/InitialInstance.h +++ b/Core/GDCore/Project/InitialInstance.h @@ -219,6 +219,13 @@ class GD_CORE_API InitialInstance { double GetCustomDepth() const { return depth; } void SetCustomDepth(double depth_) { depth = depth_; } + double GetDefaultWidth() const { return defaultWidth; } + double GetDefaultHeight() const { return defaultHeight; } + double GetDefaultDepth() const { return defaultDepth; } + void SetDefaultWidth(double width_) { defaultWidth = width_; } + void SetDefaultHeight(double height_) { defaultHeight = height_; } + void SetDefaultDepth(double depth_) { defaultDepth = depth_; } + /** * \brief Return true if the instance is locked and cannot be moved in the * IDE. @@ -366,7 +373,11 @@ class GD_CORE_API InitialInstance { */ InitialInstance& ResetPersistentUuid(); - const gd::String& GetPersistentUuid() const { return persistentUuid; } + /** + * \brief Reset the persistent UUID used to recognize + * the same initial instance between serialization. + */ + const gd::String& GetPersistentUuid() const { return persistentUuid; } ///@} private: @@ -395,6 +406,9 @@ class GD_CORE_API InitialInstance { double width; ///< Instance custom width double height; ///< Instance custom height double depth; ///< Instance custom depth + double defaultWidth = 0; ///< Instance default width as reported by InGameEditor + double defaultHeight = 0; ///< Instance default height as reported by InGameEditor + double defaultDepth = 0; ///< Instance default depth as reported by InGameEditor gd::VariablesContainer initialVariables; ///< Instance specific variables bool locked; ///< True if the instance is locked bool sealed; ///< True if the instance is sealed diff --git a/Core/GDCore/Project/Layer.cpp b/Core/GDCore/Project/Layer.cpp index 7a5ce0d8eb..e84a7128cd 100644 --- a/Core/GDCore/Project/Layer.cpp +++ b/Core/GDCore/Project/Layer.cpp @@ -23,6 +23,7 @@ Layer::Layer() camera3DNearPlaneDistance(3), camera3DFarPlaneDistance(10000), camera3DFieldOfView(45), + camera2DPlaneMaxDrawingDistance(5000), ambientLightColorR(200), ambientLightColorG(200), ambientLightColorB(200) {} @@ -56,6 +57,8 @@ void Layer::SerializeTo(SerializerElement& element) const { element.SetAttribute("camera3DFarPlaneDistance", GetCamera3DFarPlaneDistance()); element.SetAttribute("camera3DFieldOfView", GetCamera3DFieldOfView()); + element.SetAttribute("camera2DPlaneMaxDrawingDistance", + GetCamera2DPlaneMaxDrawingDistance()); SerializerElement& camerasElement = element.AddChild("cameras"); camerasElement.ConsiderAsArrayOf("camera"); @@ -99,6 +102,8 @@ void Layer::UnserializeFrom(const SerializerElement& element) { "camera3DFarPlaneDistance", 10000, "threeDFarPlaneDistance")); SetCamera3DFieldOfView(element.GetDoubleAttribute( "camera3DFieldOfView", 45, "threeDFieldOfView")); + SetCamera2DPlaneMaxDrawingDistance(element.GetDoubleAttribute( + "camera2DPlaneMaxDrawingDistance", 5000)); cameras.clear(); SerializerElement& camerasElement = element.GetChild("cameras"); diff --git a/Core/GDCore/Project/Layer.h b/Core/GDCore/Project/Layer.h index 69cb15405f..9d84debf4a 100644 --- a/Core/GDCore/Project/Layer.h +++ b/Core/GDCore/Project/Layer.h @@ -182,6 +182,8 @@ class GD_CORE_API Layer { } double GetCamera3DFieldOfView() const { return camera3DFieldOfView; } void SetCamera3DFieldOfView(double angle) { camera3DFieldOfView = angle; } + double GetCamera2DPlaneMaxDrawingDistance() const { return camera2DPlaneMaxDrawingDistance; } + void SetCamera2DPlaneMaxDrawingDistance(double distance) { camera2DPlaneMaxDrawingDistance = distance; } ///@} /** \name Cameras @@ -292,6 +294,7 @@ class GD_CORE_API Layer { double camera3DNearPlaneDistance; ///< 3D camera frustum near plan distance double camera3DFarPlaneDistance; ///< 3D camera frustum far plan distance double camera3DFieldOfView; ///< 3D camera field of view (fov) in degrees + double camera2DPlaneMaxDrawingDistance; ///< Max drawing distance of the 2D plane when in the 3D world unsigned int ambientLightColorR; ///< Ambient light color Red component unsigned int ambientLightColorG; ///< Ambient light color Green component unsigned int ambientLightColorB; ///< Ambient light color Blue component diff --git a/Core/GDCore/Project/Object.cpp b/Core/GDCore/Project/Object.cpp index 04c678ad5e..ae193850b4 100644 --- a/Core/GDCore/Project/Object.cpp +++ b/Core/GDCore/Project/Object.cpp @@ -236,6 +236,8 @@ void Object::SerializeTo(SerializerElement& element) const { const gd::Behavior& behavior = GetBehavior(allBehaviors[i]); // Default behaviors are added at the object creation according to // metadata. They don't need to be serialized. + // During the export, all behaviors are set as not default by + // `BehaviorDefaultFlagClearer` because the Runtime needs all the behaviors. if (behavior.IsDefaultBehavior()) { continue; } diff --git a/Core/GDCore/Project/Project.cpp b/Core/GDCore/Project/Project.cpp index 000bcc4833..83f2d1dc2b 100644 --- a/Core/GDCore/Project/Project.cpp +++ b/Core/GDCore/Project/Project.cpp @@ -730,6 +730,8 @@ void Project::UnserializeFrom(const SerializerElement& element) { SetPackageName(propElement.GetStringAttribute("packageName")); SetTemplateSlug(propElement.GetStringAttribute("templateSlug")); SetOrientation(propElement.GetStringAttribute("orientation", "default")); + SetEffectsHiddenInEditor( + propElement.GetBoolAttribute("areEffectsHiddenInEditor", false)); SetFolderProject(propElement.GetBoolAttribute("folderProject")); SetLastCompilationDirectory(propElement .GetChild("latestCompilationDirectory", @@ -1109,6 +1111,10 @@ void Project::SerializeTo(SerializerElement& element) const { propElement.SetAttribute("packageName", packageName); propElement.SetAttribute("templateSlug", templateSlug); propElement.SetAttribute("orientation", orientation); + if (areEffectsHiddenInEditor) { + propElement.SetBoolAttribute("areEffectsHiddenInEditor", + areEffectsHiddenInEditor); + } platformSpecificAssets.SerializeTo( propElement.AddChild("platformSpecificAssets")); loadingScreen.SerializeTo(propElement.AddChild("loadingScreen")); @@ -1150,6 +1156,8 @@ void Project::SerializeTo(SerializerElement& element) const { // end of compatibility code extensionProperties.SerializeTo(propElement.AddChild("extensionProperties")); + + playableDevicesElement.AddChild("").SetStringValue("mobile"); SerializerElement& platformsElement = propElement.AddChild("platforms"); platformsElement.ConsiderAsArrayOf("platform"); @@ -1319,6 +1327,8 @@ void Project::Init(const gd::Project& game) { sceneResourcesPreloading = game.sceneResourcesPreloading; sceneResourcesUnloading = game.sceneResourcesUnloading; + + areEffectsHiddenInEditor = game.areEffectsHiddenInEditor; } } // namespace gd diff --git a/Core/GDCore/Project/Project.h b/Core/GDCore/Project/Project.h index 09f00b1cb9..d6fc56595d 100644 --- a/Core/GDCore/Project/Project.h +++ b/Core/GDCore/Project/Project.h @@ -506,6 +506,20 @@ class GD_CORE_API Project { */ void SetCurrentPlatform(const gd::String& platformName); + /** + * Check if the effects are shown. + */ + bool AreEffectsHiddenInEditor() const { return areEffectsHiddenInEditor; } + + /** + * Define the project as playable on a mobile. + * \param enable True When false effects are not shown and a default light is + * used for 3D layers. + */ + void SetEffectsHiddenInEditor(bool enable = true) { + areEffectsHiddenInEditor = enable; + } + ///@} /** \name Factory method @@ -1165,6 +1179,9 @@ class GD_CORE_API Project { mutable unsigned int gdBuildVersion = 0; ///< The GD build version used the last ///< time the project was saved. + bool areEffectsHiddenInEditor = + false; ///< When false effects are not shown and a default light is used + ///< for 3D layers. }; } // namespace gd diff --git a/Core/GDCore/Project/PropertyDescriptor.cpp b/Core/GDCore/Project/PropertyDescriptor.cpp index 44a181f92d..1eaff4e62c 100644 --- a/Core/GDCore/Project/PropertyDescriptor.cpp +++ b/Core/GDCore/Project/PropertyDescriptor.cpp @@ -70,7 +70,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 = diff --git a/Core/GDCore/Project/ResourcesManager.cpp b/Core/GDCore/Project/ResourcesManager.cpp index c67732cbc1..e8cec010f4 100644 --- a/Core/GDCore/Project/ResourcesManager.cpp +++ b/Core/GDCore/Project/ResourcesManager.cpp @@ -99,6 +99,8 @@ std::shared_ptr ResourcesManager::CreateResource( return std::make_shared(); else if (kind == "javascript") return std::make_shared(); + else if (kind == "internal-in-game-editor-only-svg") + return std::make_shared(); std::cout << "Bad resource created (type: " << kind << ")" << std::endl; return std::make_shared(); @@ -783,6 +785,20 @@ void JavaScriptResource::SerializeTo(SerializerElement& element) const { element.SetAttribute("file", GetFile()); } +void InternalInGameEditorOnlySvgResource::SetFile(const gd::String& newFile) { + file = NormalizePathSeparator(newFile); +} + +void InternalInGameEditorOnlySvgResource::UnserializeFrom(const SerializerElement& element) { + SetUserAdded(element.GetBoolAttribute("userAdded")); + SetFile(element.GetStringAttribute("file")); +} + +void InternalInGameEditorOnlySvgResource::SerializeTo(SerializerElement& element) const { + element.SetAttribute("userAdded", IsUserAdded()); + element.SetAttribute("file", GetFile()); +} + ResourceFolder::ResourceFolder(const ResourceFolder& other) { Init(other); } ResourceFolder& ResourceFolder::operator=(const ResourceFolder& other) { diff --git a/Core/GDCore/Project/ResourcesManager.h b/Core/GDCore/Project/ResourcesManager.h index 45150b8fdc..d0f80bea97 100644 --- a/Core/GDCore/Project/ResourcesManager.h +++ b/Core/GDCore/Project/ResourcesManager.h @@ -548,7 +548,7 @@ class GD_CORE_API AtlasResource : public Resource { }; /** - * \brief Describe a video file used by a project. + * \brief Describe a JavaScript file used by a project. * * \see Resource * \ingroup ResourcesManagement @@ -573,6 +573,33 @@ class GD_CORE_API JavaScriptResource : public Resource { gd::String file; }; +/** + * \brief Describe a SVG file used by a project. + * Currently only used in the in-game editor. + * + * \see Resource + * \ingroup ResourcesManagement + */ +class GD_CORE_API InternalInGameEditorOnlySvgResource : public Resource { + public: + InternalInGameEditorOnlySvgResource() : Resource() { SetKind("internal-in-game-editor-only-svg"); }; + virtual ~InternalInGameEditorOnlySvgResource(){}; + virtual InternalInGameEditorOnlySvgResource* Clone() const override { + return new InternalInGameEditorOnlySvgResource(*this); + } + + virtual const gd::String& GetFile() const override { return file; }; + virtual void SetFile(const gd::String& newFile) override; + + virtual bool UseFile() const override { return true; } + void SerializeTo(SerializerElement& element) const override; + + void UnserializeFrom(const SerializerElement& element) override; + + private: + gd::String file; +}; + /** * \brief Inventory all resources used by a project * diff --git a/Core/GDCore/Serialization/SerializerElement.cpp b/Core/GDCore/Serialization/SerializerElement.cpp index 9fbbb240d1..bde56aeed5 100644 --- a/Core/GDCore/Serialization/SerializerElement.cpp +++ b/Core/GDCore/Serialization/SerializerElement.cpp @@ -1,7 +1,10 @@ #include "GDCore/Serialization/SerializerElement.h" +#include #include +#include "GDCore/Tools/Log.h" + namespace gd { SerializerElement SerializerElement::nullElement; @@ -56,7 +59,16 @@ SerializerElement& SerializerElement::SetAttribute(const gd::String& name, // support code using attributes. Make sure that any // existing child with this name is removed (otherwise it // would erase the attribute at serialization). - attributes[name].SetDouble(value); + + if (std::isnan(value)) { + gd::LogError("Attribute \"" + name + + "\" was set to NaN - this is not allowed (would not be " + "serialized correctly to JSON). Defaulting to 0."); + attributes[name].SetDouble(0); + } else { + attributes[name].SetDouble(value); + } + return *this; } diff --git a/Extensions/3D/A_RuntimeObject3D.ts b/Extensions/3D/A_RuntimeObject3D.ts index 9f79f5a56c..24b04bda4c 100644 --- a/Extensions/3D/A_RuntimeObject3D.ts +++ b/Extensions/3D/A_RuntimeObject3D.ts @@ -159,15 +159,9 @@ namespace gdjs { if (initialInstanceData.depth !== undefined) { this.setDepth(initialInstanceData.depth); } - if (initialInstanceData.flippedX) { - this.flipX(initialInstanceData.flippedX); - } - if (initialInstanceData.flippedY) { - this.flipY(initialInstanceData.flippedY); - } - if (initialInstanceData.flippedZ) { - this.flipZ(initialInstanceData.flippedZ); - } + this.flipX(!!initialInstanceData.flippedX); + this.flipY(!!initialInstanceData.flippedY); + this.flipZ(!!initialInstanceData.flippedZ); } setX(x: float): void { @@ -334,6 +328,18 @@ namespace gdjs { this.setAngle(gdjs.toDegrees(mesh.rotation.z)); } + override getOriginalWidth(): float { + return this._originalWidth; + } + + override getOriginalHeight(): float { + return this._originalHeight; + } + + getOriginalDepth(): float { + return this._originalDepth; + } + getWidth(): float { return this._width; } @@ -380,31 +386,6 @@ namespace gdjs { this.getRenderer().updateSize(); } - /** - * Return the width of the object for a scale of 1. - * - * It can't be 0. - */ - _getOriginalWidth(): float { - return this._originalWidth; - } - - /** - * Return the height of the object for a scale of 1. - * - * It can't be 0. - */ - _getOriginalHeight(): float { - return this._originalHeight; - } - - /** - * Return the object size on the Z axis (called "depth") when the scale equals 1. - */ - _getOriginalDepth(): float { - return this._originalDepth; - } - /** * Set the width of the object for a scale of 1. */ diff --git a/Extensions/3D/A_RuntimeObject3DRenderer.ts b/Extensions/3D/A_RuntimeObject3DRenderer.ts index e43ba9c096..c47615592a 100644 --- a/Extensions/3D/A_RuntimeObject3DRenderer.ts +++ b/Extensions/3D/A_RuntimeObject3DRenderer.ts @@ -11,6 +11,8 @@ namespace gdjs { this._object = runtimeObject; this._threeObject3D = threeObject3D; this._threeObject3D.rotation.order = 'ZYX'; + //@ts-ignore + this._threeObject3D.gdjsRuntimeObject = runtimeObject; instanceContainer .getLayer('') diff --git a/Extensions/3D/Base3DBehavior.ts b/Extensions/3D/Base3DBehavior.ts index 07c50b7576..3e8670010f 100644 --- a/Extensions/3D/Base3DBehavior.ts +++ b/Extensions/3D/Base3DBehavior.ts @@ -115,6 +115,12 @@ namespace gdjs { * Rotations around X and Y are not taken into account. */ getUnrotatedAABBMaxZ(): number; + + /** + * Return the depth of the object before any custom size is applied. + * @return The depth of the object + */ + getOriginalDepth(): float; } export interface Object3DDataContent { @@ -131,7 +137,11 @@ namespace gdjs { export namespace Base3DHandler { export const is3D = ( object: gdjs.RuntimeObject - ): object is gdjs.RuntimeObject & gdjs.Base3DHandler => { + ): object is gdjs.RuntimeObject & + gdjs.Base3DHandler & + gdjs.Resizable & + gdjs.Scalable & + gdjs.Flippable => { //@ts-ignore We are checking if the methods are present. return object.getZ && object.setZ; }; @@ -243,6 +253,10 @@ namespace gdjs { getUnrotatedAABBMaxZ(): number { return this.object.getUnrotatedAABBMaxZ(); } + + getOriginalDepth(): float { + return this.object.getOriginalDepth(); + } } gdjs.registerBehavior('Scene3D::Base3DBehavior', gdjs.Base3DBehavior); diff --git a/Extensions/3D/CustomRuntimeObject3D.ts b/Extensions/3D/CustomRuntimeObject3D.ts index 10824c95a3..5fb318e17e 100644 --- a/Extensions/3D/CustomRuntimeObject3D.ts +++ b/Extensions/3D/CustomRuntimeObject3D.ts @@ -78,15 +78,9 @@ namespace gdjs { if (initialInstanceData.depth !== undefined) { this.setDepth(initialInstanceData.depth); } - if (initialInstanceData.flippedX) { - this.flipX(initialInstanceData.flippedX); - } - if (initialInstanceData.flippedY) { - this.flipY(initialInstanceData.flippedY); - } - if (initialInstanceData.flippedZ) { - this.flipZ(initialInstanceData.flippedZ); - } + this.flipX(!!initialInstanceData.flippedX); + this.flipY(!!initialInstanceData.flippedY); + this.flipZ(!!initialInstanceData.flippedZ); } getNetworkSyncData( @@ -325,6 +319,10 @@ namespace gdjs { return this._maxZ - this._minZ; } + getOriginalDepth(): float { + return this._instanceContainer._getInitialInnerAreaDepth(); + } + override _updateUntransformedHitBoxes(): void { super._updateUntransformedHitBoxes(); diff --git a/Extensions/3D/CustomRuntimeObject3DRenderer.ts b/Extensions/3D/CustomRuntimeObject3DRenderer.ts index faa1ef71f7..314ce22e84 100644 --- a/Extensions/3D/CustomRuntimeObject3DRenderer.ts +++ b/Extensions/3D/CustomRuntimeObject3DRenderer.ts @@ -23,6 +23,8 @@ namespace gdjs { this._threeGroup = new THREE.Group(); this._threeGroup.rotation.order = 'ZYX'; + //@ts-ignore + this._threeGroup.gdjsRuntimeObject = object; const layer = parent.getLayer(''); if (layer) { diff --git a/Extensions/3D/Model3DRuntimeObject.ts b/Extensions/3D/Model3DRuntimeObject.ts index d962c53115..31a1f898cf 100644 --- a/Extensions/3D/Model3DRuntimeObject.ts +++ b/Extensions/3D/Model3DRuntimeObject.ts @@ -127,13 +127,12 @@ namespace gdjs { this._materialType = this._convertMaterialType( objectData.content.materialType ); + this._crossfadeDuration = objectData.content.crossfadeDuration || 0; this.setIsCastingShadow(objectData.content.isCastingShadow); this.setIsReceivingShadow(objectData.content.isReceivingShadow); this.onModelChanged(objectData); - this._crossfadeDuration = objectData.content.crossfadeDuration || 0; - // *ALWAYS* call `this.onCreated()` at the very end of your object constructor. this.onCreated(); } @@ -143,12 +142,13 @@ namespace gdjs { * - After the renderer was instantiated * - After reloading the model */ - private onModelChanged(objectData) { + private onModelChanged(objectData: Model3DObjectData) { this._updateModel(objectData); if (this._animations.length > 0) { this._renderer.playAnimation( this._animations[0].source, - this._animations[0].loop + this._animations[0].loop, + true ); } } @@ -198,7 +198,7 @@ namespace gdjs { this._centerPoint = getPointForLocation( newObjectData.content.centerLocation ); - this._updateModel(newObjectData); + this.onModelChanged(newObjectData); } if ( oldObjectData.content.originLocation !== @@ -221,6 +221,23 @@ namespace gdjs { ) { this.setIsReceivingShadow(newObjectData.content.isReceivingShadow); } + if (this.getInstanceContainer().getGame().isInGameEdition()) { + const oldDefaultAnimationSource = + this._animations.length > 0 ? this._animations[0].source : null; + this._animations = newObjectData.content.animations; + const newDefaultAnimationSource = + this._animations.length > 0 ? this._animations[0].source : null; + if ( + newDefaultAnimationSource && + oldDefaultAnimationSource !== newDefaultAnimationSource + ) { + this._renderer.playAnimation( + newDefaultAnimationSource, + this._animations[0].loop, + true + ); + } + } return true; } diff --git a/Extensions/3D/Model3DRuntimeObject3DRenderer.ts b/Extensions/3D/Model3DRuntimeObject3DRenderer.ts index bd247d4635..3d6423640f 100644 --- a/Extensions/3D/Model3DRuntimeObject3DRenderer.ts +++ b/Extensions/3D/Model3DRuntimeObject3DRenderer.ts @@ -371,7 +371,11 @@ namespace gdjs { this._action.paused = false; } - playAnimation(animationName: string, shouldLoop: boolean) { + playAnimation( + animationName: string, + shouldLoop: boolean, + ignoreCrossFade: boolean = false + ) { const clip = THREE.AnimationClip.findByName( this._originalModel.animations, animationName @@ -396,7 +400,11 @@ namespace gdjs { this._action.timeScale = this._model3DRuntimeObject.getAnimationSpeedScale(); - if (previousAction && previousAction !== this._action) { + if ( + previousAction && + previousAction !== this._action && + !ignoreCrossFade + ) { this._action.crossFadeFrom( previousAction, this._model3DRuntimeObject._crossfadeDuration, diff --git a/Extensions/AnchorBehavior/Extension.cpp b/Extensions/AnchorBehavior/Extension.cpp index eaae5da259..c6c6b685a7 100644 --- a/Extensions/AnchorBehavior/Extension.cpp +++ b/Extensions/AnchorBehavior/Extension.cpp @@ -31,5 +31,6 @@ void DeclareAnchorBehaviorExtension(gd::PlatformExtension& extension) { "AnchorBehavior", std::make_shared(), std::make_shared()) + .MarkAsActivatedByDefaultInEditor() .SetQuickCustomizationVisibility(gd::QuickCustomization::Hidden); } diff --git a/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js b/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js index 7b0a3430ad..9955314fb5 100644 --- a/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js +++ b/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js @@ -40,6 +40,19 @@ describe('gdjs.AnchorRuntimeBehavior', () => { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/BitmapText/bitmaptextruntimeobject.ts b/Extensions/BitmapText/bitmaptextruntimeobject.ts index 2707642b11..d28f8bba06 100644 --- a/Extensions/BitmapText/bitmaptextruntimeobject.ts +++ b/Extensions/BitmapText/bitmaptextruntimeobject.ts @@ -221,9 +221,11 @@ namespace gdjs { this.setWrappingWidth(initialInstanceData.width); this.setWrapping(true); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } override onDestroyed(): void { diff --git a/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js b/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js index fffa529b43..5d030d6c5f 100644 --- a/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js +++ b/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js @@ -29,6 +29,19 @@ describe('gdjs.DraggableRuntimeBehavior', function () { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/Leaderboards/leaderboardstools.ts b/Extensions/Leaderboards/leaderboardstools.ts index 20a14b88ad..d5ed5b4453 100644 --- a/Extensions/Leaderboards/leaderboardstools.ts +++ b/Extensions/Leaderboards/leaderboardstools.ts @@ -478,6 +478,9 @@ namespace gdjs { return new gdjs.PromiseTask( (async () => { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } const scoreSavingState = (_scoreSavingStateByLeaderboard[ leaderboardId ] = @@ -516,6 +519,9 @@ namespace gdjs { ) => new gdjs.PromiseTask( (async () => { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } const playerId = gdjs.playerAuthentication.getUserId(); const playerToken = gdjs.playerAuthentication.getUserToken(); if (!playerId || !playerToken) { @@ -843,6 +849,9 @@ namespace gdjs { leaderboardId: string, displayLoader: boolean ) { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } // First ensure we're not trying to display multiple times the same leaderboard (in which case // we "de-duplicate" the request to display it). if (leaderboardId === _requestedLeaderboardId) { diff --git a/Extensions/Lighting/InGameEditor/LightIcon.png b/Extensions/Lighting/InGameEditor/LightIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..547dfaae2fe2cb9f774348de0b4cb4f8cdb12b63 GIT binary patch literal 716 zcmV;-0yF)IP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw0002DP)t-s|Ns9=u;fg!&xpnBsHV_oX{$U(m@%HuaIn_-`ug78?Ob1`RI}xs!rkZR z=*Gs^KS!5SSE8w>(eUr~Moyedq|ru8k|Z*PR#~B4V5gFix;#UT*x2C3*5grBmy?mZ ze6-h-$?c59=toSFFFJ`jh(WOc000zpQchCpGVo5|nR5;76lUrAUP!NRy zZ=qPJBMzgUKy$p19!pBF{{Qdl&(5knh8x8P6ADxe5`()lxi7R7!5(Hm#XAavSOn!e=o>?8DErx2nL4+(kj! zW4II}Ric_pc;z>_XU>R~7~>$9LBuR!g+f|y_^1R_AT14jeQ6vrMU2_S0V&MW~j zQG5VMGQcuR5gfvl)`o%W?4qpN|gaZ%l#l)WqdpY#ciCOx}bE%_V(0000> 16) & 255, (hexNumber >> 8) & 255, hexNumber & 255]; } - getRendererObject() { + override getRendererObject() { return this._renderer.getRendererObject(); } - updateFromObjectData( + override updateFromObjectData( oldObjectData: LightObjectData, newObjectData: LightObjectData ): boolean { @@ -87,7 +87,7 @@ namespace gdjs { return true; } - getNetworkSyncData( + override getNetworkSyncData( syncOptions: GetNetworkSyncDataOptions ): LightNetworkSyncData { return { @@ -97,7 +97,7 @@ namespace gdjs { }; } - updateFromNetworkSyncData( + override updateFromNetworkSyncData( networkSyncData: LightNetworkSyncData, options: UpdateFromNetworkSyncDataOptions ): void { @@ -111,10 +111,15 @@ namespace gdjs { } } - updatePreRender(): void { + override updatePreRender(): void { this._renderer.ensureUpToDate(); } + override onDestroyed(): void { + super.onDestroyed(); + this._renderer.destroy(); + } + /** * Get the radius of the light object. * @returns radius of the light object. @@ -131,19 +136,11 @@ namespace gdjs { this._renderer.updateRadius(); } - /** - * Get the height of the light object. - * @returns height of light object. - */ - getHeight(): float { + override getHeight(): float { return 2 * this._radius; } - /** - * Get the width of the light object. - * @returns width of light object. - */ - getWidth(): float { + override getWidth(): float { return 2 * this._radius; } diff --git a/Extensions/LinkedObjects/tests/linkedobjects.spec.js b/Extensions/LinkedObjects/tests/linkedobjects.spec.js index 4164a94c0e..44b5614c0a 100644 --- a/Extensions/LinkedObjects/tests/linkedobjects.spec.js +++ b/Extensions/LinkedObjects/tests/linkedobjects.spec.js @@ -35,6 +35,19 @@ describe('gdjs.LinksManager', function () { stopSoundsOnStartup: false, title: '', usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/Multiplayer/multiplayertools.ts b/Extensions/Multiplayer/multiplayertools.ts index 1e4fb02bef..898d361624 100644 --- a/Extensions/Multiplayer/multiplayertools.ts +++ b/Extensions/Multiplayer/multiplayertools.ts @@ -1841,6 +1841,9 @@ namespace gdjs { displayLoader: boolean, openLobbiesPageIfFailure: boolean ) => { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } if (isQuickJoiningTooFast()) { return; } @@ -1860,6 +1863,9 @@ namespace gdjs { displayLoader: boolean, openLobbiesPageIfFailure: boolean ) => { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } if (isQuickJoiningTooFast()) { return; } @@ -1893,6 +1899,9 @@ namespace gdjs { export const openLobbiesWindow = async ( runtimeScene: gdjs.RuntimeScene ) => { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } if ( isLobbiesWindowOpen(runtimeScene) || gdjs.playerAuthentication.isAuthenticationWindowOpen() diff --git a/Extensions/PanelSpriteObject/panelspriteruntimeobject.ts b/Extensions/PanelSpriteObject/panelspriteruntimeobject.ts index a54a737dfd..1001f48180 100644 --- a/Extensions/PanelSpriteObject/panelspriteruntimeobject.ts +++ b/Extensions/PanelSpriteObject/panelspriteruntimeobject.ts @@ -53,6 +53,8 @@ namespace gdjs { _renderer: gdjs.PanelSpriteRuntimeObjectRenderer; + _objectData: PanelSpriteObjectData; + /** * @param instanceContainer The container the object belongs to. * @param panelSpriteObjectData The initial properties of the object @@ -62,6 +64,7 @@ namespace gdjs { panelSpriteObjectData: PanelSpriteObjectData ) { super(instanceContainer, panelSpriteObjectData); + this._objectData = panelSpriteObjectData; this._rBorder = panelSpriteObjectData.rightMargin; this._lBorder = panelSpriteObjectData.leftMargin; this._tBorder = panelSpriteObjectData.topMargin; @@ -84,6 +87,7 @@ namespace gdjs { oldObjectData: PanelSpriteObjectData, newObjectData: PanelSpriteObjectData ): boolean { + this._objectData = newObjectData; if (oldObjectData.width !== newObjectData.width) { this.setWidth(newObjectData.width); } @@ -166,9 +170,11 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } /** @@ -247,6 +253,14 @@ namespace gdjs { this.setHeight(newHeight); } + override getOriginalWidth(): float { + return this._objectData.width; + } + + override getOriginalHeight(): float { + return this._objectData.height; + } + setOpacity(opacity: float): void { if (opacity < 0) { opacity = 0; diff --git a/Extensions/ParticleSystem/particleemitterobject-pixi-renderer.ts b/Extensions/ParticleSystem/particleemitterobject-pixi-renderer.ts index 5de116b6b8..674b199ed7 100644 --- a/Extensions/ParticleSystem/particleemitterobject-pixi-renderer.ts +++ b/Extensions/ParticleSystem/particleemitterobject-pixi-renderer.ts @@ -18,12 +18,15 @@ namespace gdjs { renderer: PIXI.Container; emitter: PIXI.particles.Emitter; started: boolean = false; + helperGraphics: PIXI.Graphics | null = null; + runtimeObject: gdjs.ParticleEmitterObject; constructor( instanceContainer: gdjs.RuntimeInstanceContainer, - runtimeObject: gdjs.RuntimeObject, + runtimeObject: gdjs.ParticleEmitterObject, objectData: any ) { + this.runtimeObject = runtimeObject; const pixiRenderer = instanceContainer .getGame() .getRenderer() @@ -218,10 +221,52 @@ namespace gdjs { } update(delta: float): void { - const wasEmitting = this.emitter.emit; - this.emitter.update(delta); - if (!this.started && wasEmitting) { - this.started = true; + if ( + !this.runtimeObject.getInstanceContainer().getGame().isInGameEdition() + ) { + const wasEmitting = this.emitter.emit; + this.emitter.update(delta); + if (!this.started && wasEmitting) { + this.started = true; + } + } + if (this.helperGraphics) { + this.helperGraphics.clear(); + this.helperGraphics.position.x = this.runtimeObject.getX(); + this.helperGraphics.position.y = this.runtimeObject.getY(); + + const emitterAngle = gdjs.toRad(this.runtimeObject.getAngle()); + const sprayConeAngle = gdjs.toRad( + this.runtimeObject.getConeSprayAngle() + ); + const line1Angle = emitterAngle - sprayConeAngle / 2; + const line2Angle = emitterAngle + sprayConeAngle / 2; + const length = 64; + + this.helperGraphics.beginFill(0, 0); + this.helperGraphics.lineStyle( + 3, + this.runtimeObject.getParticleColorEnd(), + 1 + ); + this.helperGraphics.moveTo(0, 0); + this.helperGraphics.lineTo( + Math.cos(line1Angle) * length, + Math.sin(line1Angle) * length + ); + this.helperGraphics.moveTo(0, 0); + this.helperGraphics.lineTo( + Math.cos(line2Angle) * length, + Math.sin(line2Angle) * length + ); + this.helperGraphics.endFill(); + + this.helperGraphics.lineStyle(0, 0x000000, 1); + this.helperGraphics.beginFill( + this.runtimeObject.getParticleColorStart() + ); + this.helperGraphics.drawCircle(0, 0, 8); + this.helperGraphics.endFill(); } } @@ -443,6 +488,17 @@ namespace gdjs { } private static readonly frequencyMinimumValue = 0.0001; + + setHelperVisible(visible: boolean) { + if (visible && !this.helperGraphics) { + this.helperGraphics = new PIXI.Graphics(); + this.renderer.addChild(this.helperGraphics); + } else if (!visible && this.helperGraphics) { + this.helperGraphics.removeFromParent(); + this.helperGraphics.destroy(); + this.helperGraphics = null; + } + } } // @ts-ignore - Register the class to let the engine use it. diff --git a/Extensions/ParticleSystem/particleemitterobject.ts b/Extensions/ParticleSystem/particleemitterobject.ts index 15309e1d32..472397eabc 100644 --- a/Extensions/ParticleSystem/particleemitterobject.ts +++ b/Extensions/ParticleSystem/particleemitterobject.ts @@ -9,46 +9,46 @@ namespace gdjs { /** * @deprecated Data not used */ - emitterAngleA: number; - emitterForceMin: number; + emitterAngleA: float; + emitterForceMin: float; /** * Cone spray angle (degrees) */ - emitterAngleB: number; - zoneRadius: number; - emitterForceMax: number; - particleLifeTimeMax: number; - particleLifeTimeMin: number; - particleGravityY: number; - particleGravityX: number; + emitterAngleB: float; + zoneRadius: float; + emitterForceMax: float; + particleLifeTimeMax: float; + particleLifeTimeMin: float; + particleGravityY: float; + particleGravityX: float; particleColor2: string; particleColor1: string; - particleSize2: number; - particleSize1: number; + particleSize2: float; + particleSize1: float; /** * Particle max rotation speed (degrees/second) */ - particleAngle2: number; + particleAngle2: float; /** * Particle min rotation speed (degrees/second) */ - particleAngle1: number; - particleAlpha1: number; + particleAngle1: float; + particleAlpha1: float; rendererType: string; particleAlpha2: number; rendererParam2: number; rendererParam1: number; - particleSizeRandomness1: number; - particleSizeRandomness2: number; - maxParticleNb: number; + particleSizeRandomness1: float; + particleSizeRandomness2: float; + maxParticleNb: integer; additive: boolean; /** Resource name for image in particle */ textureParticleName: string; - tank: number; - flow: number; + tank: integer; + flow: float; /** Destroy the object when there is no particles? */ destroyWhenNoParticles: boolean; - jumpForwardInTimeOnCreation: number; + jumpForwardInTimeOnCreation: float; }; export type ParticleEmitterObjectData = ObjectData & @@ -107,33 +107,33 @@ namespace gdjs { /** * @deprecated Data not used */ - angleA: number; - angleB: number; - forceMin: number; + angleA: float; + angleB: float; + forceMin: float; forceMax: float; - zoneRadius: number; - lifeTimeMin: number; + zoneRadius: float; + lifeTimeMin: float; lifeTimeMax: float; - gravityX: number; - gravityY: number; - color1: number; - color2: number; - size1: number; - size2: number; + gravityX: float; + gravityY: float; + color1: integer; + color2: integer; + size1: float; + size2: float; alpha1: number; alpha2: number; rendererType: string; rendererParam1: number; rendererParam2: number; texture: string; - flow: number; - tank: number; + flow: float; + tank: integer; destroyWhenNoParticles: boolean; - particleRotationMinSpeed: number; - particleRotationMaxSpeed: number; - maxParticlesCount: number; + particleRotationMinSpeed: float; + particleRotationMaxSpeed: float; + maxParticlesCount: integer; additiveRendering: boolean; - jumpForwardInTimeOnCreation: number; + jumpForwardInTimeOnCreation: float; _jumpForwardInTimeCompleted: boolean = false; _posDirty: boolean = true; _angleDirty: boolean = true; @@ -160,6 +160,9 @@ namespace gdjs { // @ts-ignore _renderer: gdjs.ParticleEmitterObjectRenderer; + width: float = 32; + height: float = 32; + /** * @param instanceContainer the container the object belongs to * @param particleObjectData The initial properties of the object @@ -174,6 +177,9 @@ namespace gdjs { this, particleObjectData ); + if (instanceContainer.getGame().isInGameEdition()) { + this._renderer.setHelperVisible(true); + } this.angleA = particleObjectData.emitterAngleA; this.angleB = particleObjectData.emitterAngleB; this.forceMin = particleObjectData.emitterForceMin; @@ -212,32 +218,32 @@ namespace gdjs { this.onCreated(); } - setX(x: number): void { + override setX(x: float): void { if (this.x !== x) { this._posDirty = true; } super.setX(x); } - setY(y: number): void { + override setY(y: float): void { if (this.y !== y) { this._posDirty = true; } super.setY(y); } - setAngle(angle): void { + override setAngle(angle: float): void { if (this.angle !== angle) { this._angleDirty = true; } super.setAngle(angle); } - getRendererObject() { + override getRendererObject() { return this._renderer.getRendererObject(); } - updateFromObjectData( + override updateFromObjectData( oldObjectData: ParticleEmitterObjectData, newObjectData: ParticleEmitterObjectData ): boolean { @@ -370,7 +376,7 @@ namespace gdjs { return true; } - getNetworkSyncData( + override getNetworkSyncData( syncOptions: GetNetworkSyncDataOptions ): ParticleEmitterObjectNetworkSyncData { return { @@ -400,7 +406,7 @@ namespace gdjs { }; } - updateFromNetworkSyncData( + override updateFromNetworkSyncData( syncData: ParticleEmitterObjectNetworkSyncData, options: UpdateFromNetworkSyncDataOptions ): void { @@ -486,7 +492,7 @@ namespace gdjs { } } - update(instanceContainer: gdjs.RuntimeInstanceContainer): void { + override update(instanceContainer: gdjs.RuntimeInstanceContainer): void { if (this._posDirty) { this._renderer.setPosition(this.getX(), this.getY()); } @@ -574,12 +580,44 @@ namespace gdjs { } } - onDestroyed(): void { + override onDestroyed(): void { this._renderer.destroy(); super.onDestroyed(); } - getEmitterForceMin(): number { + override getOriginalWidth(): float { + return 32; + } + + override getOriginalHeight(): float { + return 32; + } + + override setWidth(width: float): void { + this.width = width; + } + + override setHeight(height: float): void { + this.height = height; + } + + override getWidth(): float { + return this.width; + } + + override getHeight(): float { + return this.height; + } + + override getDrawableX(): float { + return this.getX() - this.getCenterX(); + } + + override getDrawableY(): float { + return this.getY() - this.getCenterY(); + } + + getEmitterForceMin(): float { return this.forceMin; } @@ -593,7 +631,7 @@ namespace gdjs { } } - getEmitterForceMax(): number { + getEmitterForceMax(): float { return this.forceMax; } @@ -607,36 +645,36 @@ namespace gdjs { } } - setParticleRotationMinSpeed(speed: number): void { + setParticleRotationMinSpeed(speed: float): void { if (this.particleRotationMinSpeed !== speed) { this._particleRotationSpeedDirty = true; this.particleRotationMinSpeed = speed; } } - getParticleRotationMinSpeed(): number { + getParticleRotationMinSpeed(): float { return this.particleRotationMinSpeed; } - setParticleRotationMaxSpeed(speed: number): void { + setParticleRotationMaxSpeed(speed: float): void { if (this.particleRotationMaxSpeed !== speed) { this._particleRotationSpeedDirty = true; this.particleRotationMaxSpeed = speed; } } - getParticleRotationMaxSpeed(): number { + getParticleRotationMaxSpeed(): float { return this.particleRotationMaxSpeed; } - setMaxParticlesCount(count: number): void { + setMaxParticlesCount(count: integer): void { if (this.maxParticlesCount !== count) { this._maxParticlesCountDirty = true; this.maxParticlesCount = count; } } - getMaxParticlesCount(): number { + getMaxParticlesCount(): integer { return this.maxParticlesCount; } @@ -802,6 +840,14 @@ namespace gdjs { } } + getParticleColorStart(): integer { + return this.color1; + } + + getParticleColorEnd(): integer { + return this.color2; + } + getParticleRed1(): number { return gdjs.hexNumberToRGBArray(this.color1)[0]; } @@ -904,7 +950,7 @@ namespace gdjs { ); } - setParticleColor1AsNumber(color: number): void { + setParticleColor1AsNumber(color: integer): void { this.color1 = color; this._colorDirty = true; } @@ -915,7 +961,7 @@ namespace gdjs { ); } - setParticleColor2AsNumber(color: number): void { + setParticleColor2AsNumber(color: integer): void { this.color2 = color; this._colorDirty = true; } @@ -995,26 +1041,26 @@ namespace gdjs { this._renderer.recreate(); } - getFlow(): number { + getFlow(): float { return this.flow; } - setFlow(flow: number): void { + setFlow(flow: float): void { if (this.flow !== flow) { this.flow = flow; this._flowDirty = true; } } - getParticleCount(): number { + getParticleCount(): integer { return this._renderer.getParticleCount(); } - getTank(): number { + getTank(): integer { return this.tank; } - setTank(tank: number): void { + setTank(tank: integer): void { this.tank = tank; this._tankDirty = true; } @@ -1035,7 +1081,7 @@ namespace gdjs { } } - jumpEmitterForwardInTime(timeSkipped: number): void { + jumpEmitterForwardInTime(timeSkipped: float): void { this._renderer.update(timeSkipped); } } diff --git a/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js b/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js index c33574e364..a5f2e35757 100644 --- a/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js +++ b/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js @@ -36,6 +36,19 @@ describe('gdjs.PathfindingRuntimeBehavior', function () { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js b/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js index f84ac1dfd5..cfd6d2a1d4 100644 --- a/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js +++ b/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js @@ -39,6 +39,19 @@ describe('gdjs.PathfindingRuntimeBehavior', function () { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js b/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js index add38f0ef9..ba1aabdf13 100644 --- a/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js +++ b/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js @@ -41,6 +41,19 @@ describe('gdjs.PathfindingRuntimeBehavior', function () { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.js b/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.js index c7e0b29c4e..192ca8b397 100644 --- a/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.js +++ b/Extensions/Physics2Behavior/Box2D_v2.3.1_min.wasm.js @@ -623,10 +623,46 @@ Zv();a.b2Manifold.e_faceA=$v();a.b2Manifold.e_faceB=aw();a.b2_staticBody=bw();a. })(); gdjs.registerAsynchronouslyLoadingLibraryPromise(initializeBox2D({locateFile: function(path, prefix) { - return location.protocol === 'file:' ? - // This is needed to run on Electron. - prefix + "Extensions/Physics2Behavior/" + path : - prefix + path; + // Path should always be "Box2D_v2.3.1_min.wasm.wasm" (and if it's not, we should probably hardcode it). + if (path !== 'Box2D_v2.3.1_min.wasm.wasm') { + console.warn("'path' argument sent to locateFile in Box2D_v2.3.1_min.wasm.js is not the expected string 'Box2D_v2.3.1_min.wasm.wasm'. Loading may fail.") + } + + // Prefix is typically: + // Games ("exported", standalone game): + // - Web game: "https://games.gdevelop-app.com/[...]/Extensions/Physics2Behavior/" + // - Cordova Android: "https://localhost/Extensions/Physics2Behavior/". + // - Cordova iOS: "ionic://localhost/Extensions/Physics2Behavior/". + // - Electron macOS: "/private/var/[...]/Contents/Resources/app.asar/app/" (notice the missing folder). + // - Electron Windows: "C:\Users\[...]\AppData\Local\[...]\resources\app.asar\app/" (notice the missing folder). + // Preview (in the editor): + // - Web app preview (dev editor): "http://localhost:5002/Runtime/Extensions/Physics2Behavior/" + // - Web app preview (production editor): "https://resources.gdevelop-app.com/[...]/Runtime/Extensions/Physics2Behavior/" + // - Electron app preview (dev editor): "/var/[...]/preview/" (notice the missing folder). + // - Electron app preview (production editor): "/var/[...]/preview/" (notice the missing folder). + // In-game editor: + // - Web app (dev editor): "http://localhost:5002/Runtime/Extensions/Physics2Behavior/" + // - Web app (production editor): "https://resources.gdevelop-app.com/[...]/Runtime/Extensions/Physics2Behavior/" + // - Electron app (dev editor): "file:///var/[...]/in-game-editor-preview/Extensions/Physics2Behavior/" + // - Electron app (production editor): "file:///var/[...]/in-game-editor-preview/Extensions/Physics2Behavior/" + + // If the prefix is a full URL, it's a full URL to the folder containing this JS file. + // Sill consider the case where the folder could have been missing. + let url; + if (prefix.startsWith('http:') || prefix.startsWith('https:')) { + url = prefix.endsWith('Extensions/Physics2Behavior/') ? + prefix + path : + prefix + 'Extensions/Physics2Behavior/' + path; + } else { + // Electron or Cordova iOS will fall in this case. + // We can't use this simple solution for http/https because + // on the web-app, the runtime is not necessarily hosted + // on the same domain as where the game generated files are served (so "prefix" is needed). + url = "Extensions/Physics2Behavior/" + path; + } + + console.info(`Box2D wasm file is being loaded from path "${path}" with prefix "${prefix}". Resolved URL: "${url}".`); + return url; }}).then(box2d => { window.Box2D = box2d; })); diff --git a/Extensions/PlayerAuthentication/playerauthenticationtools.ts b/Extensions/PlayerAuthentication/playerauthenticationtools.ts index e2750b45d8..8a2618925e 100644 --- a/Extensions/PlayerAuthentication/playerauthenticationtools.ts +++ b/Extensions/PlayerAuthentication/playerauthenticationtools.ts @@ -647,6 +647,9 @@ namespace gdjs { export const displayAuthenticationBanner = function ( runtimeScene: gdjs.RuntimeScene ) { + if (runtimeScene.getGame().isInGameEdition()) { + return; + } if (_authenticationBanner) { // Banner already displayed, ensure it's visible. _authenticationBanner.style.opacity = '1'; @@ -1042,6 +1045,10 @@ namespace gdjs { ): gdjs.PromiseTask<{ status: 'logged' | 'errored' | 'dismissed' }> => new gdjs.PromiseTask( new Promise((resolve) => { + if (runtimeScene.getGame().isInGameEdition()) { + resolve({ status: 'dismissed' }); + } + // Create the authentication container for the player to wait. const domElementContainer = runtimeScene .getGame() diff --git a/Extensions/PrimitiveDrawing/Extension.cpp b/Extensions/PrimitiveDrawing/Extension.cpp index e7e04f46de..c45fd34a26 100644 --- a/Extensions/PrimitiveDrawing/Extension.cpp +++ b/Extensions/PrimitiveDrawing/Extension.cpp @@ -37,6 +37,11 @@ void DeclarePrimitiveDrawingExtension(gd::PlatformExtension& extension) { .AddDefaultBehavior("ScalableCapability::ScalableBehavior") .AddDefaultBehavior("FlippableCapability::FlippableBehavior"); + obj.AddInGameEditorResource() + .SetResourceName("InGameEditor-ShapePainterIcon") + .SetFilePath("Extensions/PrimitiveDrawing/InGameEditor/ShapePainterIcon.png") + .SetKind("image"); + #if defined(GD_IDE_ONLY) obj.AddAction( "Rectangle", diff --git a/Extensions/PrimitiveDrawing/InGameEditor/ShapePainterIcon.png b/Extensions/PrimitiveDrawing/InGameEditor/ShapePainterIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..547bc2481839af196369b33fa448d275a57bd696 GIT binary patch literal 394 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7e6l0AZa85r9685nwi_%BdVqXPp& zZ6yQ4%Weh+o2Lv6l4pu-TFZfI%>sNvT>t<7@3ZlV*9H*s-}uC8;YIbi=QZbDuyLEA zy6T~}d57)l`#=TCbI#ki&oHr{tYXv()MDx>D-1M&yCldjnBj5g6`|0_SC$DZGo5=c z4=Bu-#ofc|V;9lzVh5S#pL_>?KX^95 z`1{PFg&}M=9X+*DZv^Upnd)P_^wZ(~jtFOlwqvKbX};sRxow6P&kAI=`Yk#+`*cy@mdbtau4uLFI{le{Gjn;@ b{A+BzPq;2FnA5ce=xPQ}S3j3^P6 { instances: instances || [], variables: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 0, + gridHeight: 0, + gridOffsetX: 0, + gridOffsetY: 0, + gridColor: 0, + gridAlpha: 0, + snap: false, + }, }); describe('Save State Basics', () => { diff --git a/Extensions/Spine/managers/pixi-spine-atlas-manager.ts b/Extensions/Spine/managers/pixi-spine-atlas-manager.ts index 4858c8ce59..7ba1d51cee 100644 --- a/Extensions/Spine/managers/pixi-spine-atlas-manager.ts +++ b/Extensions/Spine/managers/pixi-spine-atlas-manager.ts @@ -203,13 +203,17 @@ namespace gdjs { } unloadResource(resourceData: ResourceData): void { - const loadedSpineAtlas = this._loadedSpineAtlases.get(resourceData); + const loadedSpineAtlas = this._loadedSpineAtlases.getFromName( + resourceData.name + ); if (loadedSpineAtlas) { loadedSpineAtlas.dispose(); this._loadedSpineAtlases.delete(resourceData); } - const loadingSpineAtlas = this._loadingSpineAtlases.get(resourceData); + const loadingSpineAtlas = this._loadingSpineAtlases.getFromName( + resourceData.name + ); if (loadingSpineAtlas) { loadingSpineAtlas.then((atl) => atl.dispose()); this._loadingSpineAtlases.delete(resourceData); diff --git a/Extensions/Spine/spineruntimeobject.ts b/Extensions/Spine/spineruntimeobject.ts index 7c7cbee64e..74479e9b1e 100644 --- a/Extensions/Spine/spineruntimeobject.ts +++ b/Extensions/Spine/spineruntimeobject.ts @@ -218,15 +218,13 @@ namespace gdjs { this.setSize(initialInstanceData.width, initialInstanceData.height); this.invalidateHitboxes(); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } - if (initialInstanceData.flippedX) { - this.flipX(initialInstanceData.flippedX); - } - if (initialInstanceData.flippedY) { - this.flipY(initialInstanceData.flippedY); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); + this.flipX(!!initialInstanceData.flippedX); + this.flipY(!!initialInstanceData.flippedY); } getDrawableX(): number { diff --git a/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js b/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js index 66814a36b3..49a39f4771 100644 --- a/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js +++ b/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js @@ -61,6 +61,19 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme instances: [], variables: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/TextInput/textinputruntimeobject.ts b/Extensions/TextInput/textinputruntimeobject.ts index 7a3fe7b298..2476989543 100644 --- a/Extensions/TextInput/textinputruntimeobject.ts +++ b/Extensions/TextInput/textinputruntimeobject.ts @@ -141,7 +141,9 @@ namespace gdjs { ); this._borderOpacity = objectData.content.borderOpacity; this._borderWidth = objectData.content.borderWidth; - this._disabled = objectData.content.disabled; + this._disabled = instanceContainer.getGame().isInGameEdition() + ? true + : objectData.content.disabled; this._readOnly = objectData.content.readOnly; this._spellCheck = objectData.content.spellCheck !== undefined @@ -334,9 +336,11 @@ namespace gdjs { this.setHeight(initialInstanceData.height); this._renderer.updatePadding(); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } onScenePaused(runtimeScene: gdjs.RuntimeScene): void { @@ -566,6 +570,9 @@ namespace gdjs { } setDisabled(value: boolean) { + if (this.getInstanceContainer().getGame().isInGameEdition()) { + return; + } this._disabled = value; this._renderer.updateDisabled(); } diff --git a/Extensions/TextObject/textruntimeobject.ts b/Extensions/TextObject/textruntimeobject.ts index 1cdaf371f8..f826760317 100644 --- a/Extensions/TextObject/textruntimeobject.ts +++ b/Extensions/TextObject/textruntimeobject.ts @@ -340,7 +340,9 @@ namespace gdjs { return this._renderer.getRendererObject(); } - override update(instanceContainer: gdjs.RuntimeInstanceContainer): void { + override updatePreRender( + instanceContainer: gdjs.RuntimeInstanceContainer + ): void { this._renderer.ensureUpToDate(); } @@ -358,9 +360,11 @@ namespace gdjs { } else { this.setWrapping(false); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } /** diff --git a/Extensions/TileMap/simpletilemapruntimeobject.ts b/Extensions/TileMap/simpletilemapruntimeobject.ts index 9bad3bbc7a..c307369f2f 100644 --- a/Extensions/TileMap/simpletilemapruntimeobject.ts +++ b/Extensions/TileMap/simpletilemapruntimeobject.ts @@ -240,9 +240,11 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); // 4. Update position (calculations based on renderer's dimensions). this._renderer.updatePosition(); @@ -453,6 +455,14 @@ namespace gdjs { return this._renderer.getHeight(); } + override getOriginalWidth(): float { + return this.getTileMapWidth(); + } + + override getOriginalHeight(): float { + return this.getTileMapHeight(); + } + getScaleX(): float { return this._renderer.getScaleX(); } diff --git a/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js b/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js index 8c6ee52b0a..3aaa06c107 100644 --- a/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js +++ b/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js @@ -57,6 +57,19 @@ describe('gdjs.TileMapCollisionMaskRuntimeObject', function () { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/TileMap/tilemapruntimeobject.ts b/Extensions/TileMap/tilemapruntimeobject.ts index fd0899d11d..fc8fe634c5 100644 --- a/Extensions/TileMap/tilemapruntimeobject.ts +++ b/Extensions/TileMap/tilemapruntimeobject.ts @@ -197,9 +197,11 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } updateTileMap(): void { @@ -343,6 +345,14 @@ namespace gdjs { this.setHeight(newHeight); } + override getOriginalWidth(): float { + return this.getTileMapWidth(); + } + + override getOriginalHeight(): float { + return this.getTileMapHeight(); + } + /** * Get the scale of the object (or the geometric mean of the X and Y scale in case they are different). * diff --git a/Extensions/TiledSpriteObject/tiledspriteruntimeobject.ts b/Extensions/TiledSpriteObject/tiledspriteruntimeobject.ts index 24cbede27b..97763dd157 100644 --- a/Extensions/TiledSpriteObject/tiledspriteruntimeobject.ts +++ b/Extensions/TiledSpriteObject/tiledspriteruntimeobject.ts @@ -42,6 +42,8 @@ namespace gdjs { _renderer: gdjs.TiledSpriteRuntimeObjectRenderer; + _objectData: TiledSpriteObjectData; + /** * @param instanceContainer The container the object belongs to. * @param tiledSpriteObjectData The initial properties of the object @@ -51,6 +53,7 @@ namespace gdjs { tiledSpriteObjectData: TiledSpriteObjectData ) { super(instanceContainer, tiledSpriteObjectData); + this._objectData = tiledSpriteObjectData; this._renderer = new gdjs.TiledSpriteRuntimeObjectRenderer( this, instanceContainer, @@ -66,6 +69,7 @@ namespace gdjs { } updateFromObjectData(oldObjectData, newObjectData): boolean { + this._objectData = newObjectData; if (oldObjectData.texture !== newObjectData.texture) { this.setTexture(newObjectData.texture, this.getRuntimeScene()); } @@ -129,9 +133,11 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } /** @@ -223,6 +229,14 @@ namespace gdjs { this.setHeight(height); } + override getOriginalWidth(): float { + return this._objectData.width; + } + + override getOriginalHeight(): float { + return this._objectData.height; + } + /** * Set the offset on the X-axis when displaying the image of the Tiled Sprite object. * @param xOffset The new offset on the X-axis. diff --git a/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js b/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js index 3ae079060d..abaab93dd7 100644 --- a/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js +++ b/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js @@ -34,6 +34,19 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }, usedExtensionsWithVariablesData: [], }); diff --git a/Extensions/Video/videoruntimeobject.ts b/Extensions/Video/videoruntimeobject.ts index 3f0d6d5470..0cfbbd3478 100644 --- a/Extensions/Video/videoruntimeobject.ts +++ b/Extensions/Video/videoruntimeobject.ts @@ -151,9 +151,11 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); } onDestroyed(): void { diff --git a/GDJS/GDJS/IDE/Exporter.cpp b/GDJS/GDJS/IDE/Exporter.cpp index 3174f361c1..a834c9fd5f 100644 --- a/GDJS/GDJS/IDE/Exporter.cpp +++ b/GDJS/GDJS/IDE/Exporter.cpp @@ -13,11 +13,15 @@ #include "GDCore/CommonTools.h" #include "GDCore/Events/CodeGeneration/DiagnosticReport.h" +#include "GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h" #include "GDCore/IDE/AbstractFileSystem.h" #include "GDCore/IDE/Events/UsedExtensionsFinder.h" #include "GDCore/IDE/Project/ProjectResourcesCopier.h" #include "GDCore/IDE/Project/SceneResourcesFinder.h" #include "GDCore/IDE/ProjectStripper.h" +#include "GDCore/Project/EventsBasedObject.h" +#include "GDCore/Project/EventsBasedObjectVariant.h" +#include "GDCore/Project/EventsFunctionsExtension.h" #include "GDCore/Project/ExternalEvents.h" #include "GDCore/Project/ExternalLayout.h" #include "GDCore/Project/Layout.h" @@ -47,7 +51,7 @@ Exporter::~Exporter() {} bool Exporter::ExportProjectForPixiPreview( const PreviewExportOptions &options) { ExporterHelper helper(fs, gdjsRoot, codeOutputDir); - return helper.ExportProjectForPixiPreview(options); + return helper.ExportProjectForPixiPreview(options, includesFiles); } bool Exporter::ExportWholePixiProject(const ExportOptions &options) { @@ -80,7 +84,7 @@ bool Exporter::ExportWholePixiProject(const ExportOptions &options) { // Prepare the export directory fs.MkDir(exportDir); - std::vector includesFiles; + includesFiles.clear(); std::vector resourcesFiles; // Export the resources (before generating events as some resources @@ -98,6 +102,7 @@ bool Exporter::ExportWholePixiProject(const ExportOptions &options) { helper.AddLibsInclude( /*pixiRenderers=*/true, usedExtensionsResult.Has3DObjects(), + /*isInGameEditor=*/false, /*includeWebsocketDebuggerClient=*/false, /*includeWindowMessageDebuggerClient=*/false, /*includeMinimalDebuggerClient=*/false, @@ -120,7 +125,7 @@ bool Exporter::ExportWholePixiProject(const ExportOptions &options) { helper.ExportEffectIncludes(exportedProject, includesFiles); // Export events - if (!helper.ExportEventsCode(exportedProject, + if (!helper.ExportScenesEventsCode(exportedProject, codeOutputDir, includesFiles, wholeProjectDiagnosticReport, @@ -130,29 +135,11 @@ bool Exporter::ExportWholePixiProject(const ExportOptions &options) { return false; } - auto projectUsedResources = - gd::SceneResourcesFinder::FindProjectResources(exportedProject); - std::unordered_map> scenesUsedResources; - for (std::size_t layoutIndex = 0; - layoutIndex < exportedProject.GetLayoutsCount(); - layoutIndex++) { - auto &layout = exportedProject.GetLayout(layoutIndex); - scenesUsedResources[layout.GetName()] = - gd::SceneResourcesFinder::FindSceneResources(exportedProject, layout); - } - - // Strip the project (*after* generating events as the events may use - // stripped things like objects groups...)... - gd::ProjectStripper::StripProjectForExport(exportedProject); - //...and export it gd::SerializerElement noRuntimeGameOptions; - helper.ExportProjectData(fs, - exportedProject, - codeOutputDir + "/data.js", - noRuntimeGameOptions, - projectUsedResources, - scenesUsedResources); + std::vector noInGameEditorResources; + helper.ExportProjectData(fs, exportedProject, codeOutputDir + "/data.js", + noRuntimeGameOptions, false, noInGameEditorResources); includesFiles.push_back(codeOutputDir + "/data.js"); helper.ExportIncludesAndLibs(includesFiles, exportDir, false); @@ -215,4 +202,18 @@ bool Exporter::ExportWholePixiProject(const ExportOptions &options) { return true; } +void Exporter::SerializeProjectData(const gd::Project &project, + const PreviewExportOptions &options, + gd::SerializerElement &projectDataElement) { + std::vector noInGameEditorResources; + ExporterHelper::SerializeProjectData(fs, project, options, projectDataElement, noInGameEditorResources); +} + +void Exporter::SerializeRuntimeGameOptions( + const PreviewExportOptions &options, + gd::SerializerElement &runtimeGameOptionsElement) { + ExporterHelper::SerializeRuntimeGameOptions( + fs, gdjsRoot, options, includesFiles, runtimeGameOptionsElement); +} + } // namespace gdjs diff --git a/GDJS/GDJS/IDE/Exporter.h b/GDJS/GDJS/IDE/Exporter.h index 182f50c27c..75cc789060 100644 --- a/GDJS/GDJS/IDE/Exporter.h +++ b/GDJS/GDJS/IDE/Exporter.h @@ -16,6 +16,7 @@ class Project; class Layout; class ExternalLayout; class AbstractFileSystem; +class SerializerElement; } // namespace gd namespace gdjs { struct PreviewExportOptions; @@ -64,7 +65,33 @@ class Exporter { codeOutputDir = codeOutputDir_; } - private: + /** + * \brief Serialize a project without its events to JSON + * + * \param project The project to be exported + * \param options The content of the extra configuration + * \param projectDataElement The element where the project data is serialized + */ + void SerializeProjectData(const gd::Project &project, + const PreviewExportOptions &options, + gd::SerializerElement &projectDataElement); + + /** + * \brief Serialize the content of the extra configuration to store + * in gdjs.runtimeGameOptions to JSON + * + * \warning `ExportProjectForPixiPreview` must be called first to serialize + * the list of scripts files. + * + * \param options The content of the extra configuration + * \param runtimeGameOptionsElement The element where the game options are + * serialized + */ + void + SerializeRuntimeGameOptions(const PreviewExportOptions &options, + gd::SerializerElement &runtimeGameOptionsElement); + +private: gd::AbstractFileSystem& fs; ///< The abstract file system to be used for exportation. gd::String lastError; ///< The last error that occurred. @@ -72,6 +99,8 @@ class Exporter { gdjsRoot; ///< The root directory of GDJS, used to copy runtime files. gd::String codeOutputDir; ///< The directory where JS code is outputted. Will ///< be then copied to the final output directory. + std::vector + includesFiles; ///< The list of scripts files - useful for hot-reloading }; } // namespace gdjs diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index faf9a1ccf5..57f57b0867 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -21,6 +21,7 @@ #include "GDCore/Events/CodeGeneration/EffectsCodeGenerator.h" #include "GDCore/Extensions/Metadata/DependencyMetadata.h" #include "GDCore/Extensions/Metadata/MetadataProvider.h" +#include "GDCore/Extensions/Metadata/InGameEditorResourceMetadata.h" #include "GDCore/Extensions/Platform.h" #include "GDCore/Extensions/PlatformExtension.h" #include "GDCore/IDE/AbstractFileSystem.h" @@ -28,10 +29,13 @@ #include "GDCore/IDE/Events/UsedExtensionsFinder.h" #include "GDCore/IDE/ExportedDependencyResolver.h" #include "GDCore/IDE/Project/ProjectResourcesCopier.h" +#include "GDCore/IDE/Project/ResourcesMergingHelper.h" #include "GDCore/IDE/Project/SceneResourcesFinder.h" #include "GDCore/IDE/ProjectStripper.h" +#include "GDCore/IDE/ResourceExposer.h" #include "GDCore/IDE/SceneNameMangler.h" #include "GDCore/Project/EventsBasedObject.h" +#include "GDCore/Project/EventsBasedObjectVariant.h" #include "GDCore/Project/EventsFunctionsExtension.h" #include "GDCore/Project/ExternalEvents.h" #include "GDCore/Project/ExternalLayout.h" @@ -58,7 +62,6 @@ double GetTimeSpent(double previousTime) { return GetTimeNow() - previousTime; } double LogTimeSpent(const gd::String &name, double previousTime) { gd::LogStatus(name + " took " + gd::String::From(GetTimeSpent(previousTime)) + "ms"); - std::cout << std::endl; return GetTimeNow(); } } // namespace @@ -104,128 +107,325 @@ ExporterHelper::ExporterHelper(gd::AbstractFileSystem &fileSystem, : fs(fileSystem), gdjsRoot(gdjsRoot_), codeOutputDir(codeOutputDir_) {}; bool ExporterHelper::ExportProjectForPixiPreview( - const PreviewExportOptions &options) { + const PreviewExportOptions &options, + std::vector &includesFiles) { + + if (options.isInGameEdition && !options.shouldReloadProjectData && + !options.shouldReloadLibraries && !options.shouldGenerateScenesEventsCode && + !options.shouldClearExportFolder) { + gd::LogStatus("Skip project export entirely"); + return ""; + } + double previousTime = GetTimeNow(); fs.MkDir(options.exportPath); - fs.ClearDir(options.exportPath); - std::vector includesFiles; + if (options.shouldClearExportFolder) { + fs.ClearDir(options.exportPath); + } + includesFiles.clear(); std::vector resourcesFiles; + std::vector inGameEditorResources; + // TODO Try to remove side effects to avoid the copy // that destroys the AST in cache. gd::Project exportedProject = options.project; - const gd::Project &immutableProject = exportedProject; + const gd::Project &immutableProject = options.project; + previousTime = LogTimeSpent("Project cloning", previousTime); - if (options.fullLoadingScreen) { - // Use project properties fallback to set empty properties - if (exportedProject.GetAuthorIds().empty() && - !options.fallbackAuthorId.empty()) { - exportedProject.GetAuthorIds().push_back(options.fallbackAuthorId); - } - if (exportedProject.GetAuthorUsernames().empty() && - !options.fallbackAuthorUsername.empty()) { - exportedProject.GetAuthorUsernames().push_back( - options.fallbackAuthorUsername); + if (options.isInGameEdition) { + if (options.shouldReloadProjectData || + options.shouldGenerateScenesEventsCode || + options.shouldClearExportFolder) { + auto projectDirectory = fs.DirNameFrom(exportedProject.GetProjectFile()); + gd::ResourcesMergingHelper resourcesMergingHelper( + exportedProject.GetResourcesManager(), fs); + resourcesMergingHelper.SetBaseDirectory(projectDirectory); + resourcesMergingHelper.SetShouldUseOriginalAbsoluteFilenames(); + gd::ResourceExposer::ExposeWholeProjectResources(exportedProject, + resourcesMergingHelper); + + previousTime = LogTimeSpent("Resource path resolving", previousTime); } + gd::LogStatus("Resource export is skipped"); } else { - // Most of the time, we skip the logo and minimum duration so that - // the preview start as soon as possible. - exportedProject.GetLoadingScreen() - .ShowGDevelopLogoDuringLoadingScreen(false) - .SetMinDuration(0); - exportedProject.GetWatermark().ShowGDevelopWatermark(false); + // Export resources (*before* generating events as some resources filenames + // may be updated) + ExportResources(fs, exportedProject, options.exportPath); + + previousTime = LogTimeSpent("Resource export", previousTime); } - // Export resources (*before* generating events as some resources filenames - // may be updated) - ExportResources(fs, exportedProject, options.exportPath); - - previousTime = LogTimeSpent("Resource export", previousTime); - - // Compatibility with GD <= 5.0-beta56 - // Stay compatible with text objects declaring their font as just a filename - // without a font resource - by manually adding these resources. - AddDeprecatedFontFilesToFontResources( - fs, exportedProject.GetResourcesManager(), options.exportPath); - // end of compatibility code - - auto usedExtensionsResult = - gd::UsedExtensionsFinder::ScanProject(exportedProject); - - // Export engine libraries - AddLibsInclude(/*pixiRenderers=*/true, - usedExtensionsResult.Has3DObjects(), - /*includeWebsocketDebuggerClient=*/ - !options.websocketDebuggerServerAddress.empty(), - /*includeWindowMessageDebuggerClient=*/ - options.useWindowMessageDebuggerClient, - /*includeMinimalDebuggerClient=*/ - options.useMinimalDebuggerClient, - /*includeCaptureManager=*/ - !options.captureOptions.IsEmpty(), - /*includeInAppTutorialMessage*/ - !options.inAppTutorialMessageInPreview.empty(), - immutableProject.GetLoadingScreen().GetGDevelopLogoStyle(), - includesFiles); - - // Export files for free function, object and behaviors - for (const auto &includeFile : usedExtensionsResult.GetUsedIncludeFiles()) { - InsertUnique(includesFiles, includeFile); - } - for (const auto &requiredFile : usedExtensionsResult.GetUsedRequiredFiles()) { - InsertUnique(resourcesFiles, requiredFile); + if (options.shouldReloadProjectData || + options.shouldGenerateScenesEventsCode || + options.shouldClearExportFolder) { + // Compatibility with GD <= 5.0-beta56 + // Stay compatible with text objects declaring their font as just a filename + // without a font resource - by manually adding these resources. + AddDeprecatedFontFilesToFontResources( + fs, exportedProject.GetResourcesManager(), options.exportPath); + // end of compatibility code } - // Export effects (after engine libraries as they auto-register themselves to - // the engine) - ExportEffectIncludes(exportedProject, includesFiles); + std::vector noUsedSourceFiles; + std::vector &usedSourceFiles = noUsedSourceFiles; + if (options.shouldReloadLibraries || options.shouldClearExportFolder) { + auto usedExtensionsResult = + gd::UsedExtensionsFinder::ScanProject(exportedProject); + usedSourceFiles = usedExtensionsResult.GetUsedSourceFiles(); - previousTime = LogTimeSpent("Include files export", previousTime); + // Export engine libraries + AddLibsInclude(/*pixiRenderers=*/true, + /*pixiInThreeRenderers=*/ + usedExtensionsResult.Has3DObjects(), + /*isInGameEdition=*/ + options.isInGameEdition, + /*includeWebsocketDebuggerClient=*/ + !options.websocketDebuggerServerAddress.empty(), + /*includeWindowMessageDebuggerClient=*/ + options.useWindowMessageDebuggerClient, + /*includeMinimalDebuggerClient=*/ + options.useMinimalDebuggerClient, + /*includeCaptureManager=*/ + !options.captureOptions.IsEmpty(), + /*includeInAppTutorialMessage*/ + !options.inAppTutorialMessageInPreview.empty(), + immutableProject.GetLoadingScreen().GetGDevelopLogoStyle(), + includesFiles); - if (!options.projectDataOnlyExport) { + // Export files for free function, object and behaviors + for (const auto &includeFile : usedExtensionsResult.GetUsedIncludeFiles()) { + InsertUnique(includesFiles, includeFile); + } + for (const auto &requiredFile : usedExtensionsResult.GetUsedRequiredFiles()) { + InsertUnique(resourcesFiles, requiredFile); + } + + if (options.isInGameEdition) { + // List the in-game editor resources used by the project, so they can + // be later included in the exported project resources. + for (const auto &inGameEditorResource : usedExtensionsResult.GetUsedInGameEditorResources()) { + inGameEditorResources.push_back(inGameEditorResource); + + // Always use absolute paths for in-game editor resources. + // There are not copied and instead directly refer to the file in the Runtime folder. + gd::String resourceFile = inGameEditorResource.GetFilePath(); + if (!fs.IsAbsolute(resourceFile)) { + fs.MakeAbsolute(resourceFile, gdjsRoot + "/Runtime"); + } + inGameEditorResources.back().SetFilePath(resourceFile); + } + + // TODO Scan the objects and events of event-based objects + // (it could be an alternative method ScanProjectAndEventsBasedObjects in + // UsedExtensionsFinder). + // This is already done by UsedExtensionsFinder, but maybe it shouldn't. + + // Export all event-based objects because they can be edited even if they + // are not used yet. + for (std::size_t e = 0; + e < exportedProject.GetEventsFunctionsExtensionsCount(); e++) { + auto &eventsFunctionsExtension = + exportedProject.GetEventsFunctionsExtension(e); + + for (auto &&eventsBasedObjectUniquePtr : + eventsFunctionsExtension.GetEventsBasedObjects() + .GetInternalVector()) { + auto eventsBasedObject = eventsBasedObjectUniquePtr.get(); + + auto metadata = gd::MetadataProvider::GetExtensionAndObjectMetadata( + exportedProject.GetCurrentPlatform(), + gd::PlatformExtension::GetObjectFullType( + eventsFunctionsExtension.GetName(), + eventsBasedObject->GetName())); + for (auto &&includeFile : metadata.GetMetadata().includeFiles) { + InsertUnique(includesFiles, includeFile); + } + for (auto &behaviorType : + metadata.GetMetadata().GetDefaultBehaviors()) { + auto behaviorMetadata = + gd::MetadataProvider::GetExtensionAndBehaviorMetadata( + exportedProject.GetCurrentPlatform(), behaviorType); + for (auto &&includeFile : + behaviorMetadata.GetMetadata().includeFiles) { + InsertUnique(includesFiles, includeFile); + } + } + } + } + } + + // Export effects (after engine libraries as they auto-register themselves to + // the engine) + ExportEffectIncludes(exportedProject, includesFiles); + + previousTime = LogTimeSpent("Include files export", previousTime); + } + else { + gd::LogStatus("Include files export is skipped"); + } + + if (options.shouldGenerateScenesEventsCode) { gd::WholeProjectDiagnosticReport &wholeProjectDiagnosticReport = options.project.GetWholeProjectDiagnosticReport(); wholeProjectDiagnosticReport.Clear(); // Generate events code - if (!ExportEventsCode(immutableProject, + if (!ExportScenesEventsCode(immutableProject, codeOutputDir, includesFiles, wholeProjectDiagnosticReport, true)) { return false; } - previousTime = LogTimeSpent("Events code export", previousTime); } - - auto projectUsedResources = - gd::SceneResourcesFinder::FindProjectResources(exportedProject); - std::unordered_map> scenesUsedResources; - for (std::size_t layoutIndex = 0; - layoutIndex < exportedProject.GetLayoutsCount(); - layoutIndex++) { - auto &layout = exportedProject.GetLayout(layoutIndex); - scenesUsedResources[layout.GetName()] = - gd::SceneResourcesFinder::FindSceneResources(exportedProject, layout); + else { + gd::LogStatus("Events code export is skipped"); } - // Strip the project (*after* generating events as the events may use stripped - // things (objects groups...)) - gd::ProjectStripper::StripProjectForExport(exportedProject); - exportedProject.SetFirstLayout(options.layoutName); + if (options.shouldReloadProjectData || options.shouldClearExportFolder) { - previousTime = LogTimeSpent("Data stripping", previousTime); + if (options.fullLoadingScreen) { + // Use project properties fallback to set empty properties + if (exportedProject.GetAuthorIds().empty() && + !options.fallbackAuthorId.empty()) { + exportedProject.GetAuthorIds().push_back(options.fallbackAuthorId); + } + if (exportedProject.GetAuthorUsernames().empty() && + !options.fallbackAuthorUsername.empty()) { + exportedProject.GetAuthorUsernames().push_back( + options.fallbackAuthorUsername); + } + } else { + // Most of the time, we skip the logo and minimum duration so that + // the preview start as soon as possible. + exportedProject.GetLoadingScreen() + .ShowGDevelopLogoDuringLoadingScreen(false) + .SetMinDuration(0); + exportedProject.GetWatermark().ShowGDevelopWatermark(false); + } + gd::SerializerElement runtimeGameOptions; + ExporterHelper::SerializeRuntimeGameOptions(fs, gdjsRoot, options, + includesFiles, runtimeGameOptions); + ExportProjectData(fs, exportedProject, codeOutputDir + "/data.js", + runtimeGameOptions, options.isInGameEdition, + inGameEditorResources); + includesFiles.push_back(codeOutputDir + "/data.js"); + + previousTime = LogTimeSpent("Project data export", previousTime); + } + else { + gd::LogStatus("Project data export is skipped"); + } + + if (options.shouldReloadLibraries || options.shouldClearExportFolder) { + // Copy all the dependencies and their source maps + ExportIncludesAndLibs(includesFiles, options.exportPath, true); + ExportIncludesAndLibs(resourcesFiles, options.exportPath, true); + + // TODO Build a full includesFiles list without actually doing export or + // generation. + if (options.shouldGenerateScenesEventsCode || options.shouldClearExportFolder) { + // Create the index file + if (!ExportIndexFile(exportedProject, gdjsRoot + "/Runtime/index.html", + options.exportPath, includesFiles, usedSourceFiles, + options.nonRuntimeScriptsCacheBurst, + "gdjs.runtimeGameOptions")) { + return false; + } + } + previousTime = LogTimeSpent("Include and libs export", previousTime); + } else { + gd::LogStatus("Include and libs export is skipped"); + } + + return true; +} + +gd::String ExporterHelper::ExportProjectData( + gd::AbstractFileSystem &fs, gd::Project &project, gd::String filename, + const gd::SerializerElement &runtimeGameOptions, bool isInGameEdition, + const std::vector &inGameEditorResources) { + fs.MkDir(fs.DirNameFrom(filename)); + + gd::SerializerElement projectDataElement; + ExporterHelper::StriptAndSerializeProjectData(project, projectDataElement, + isInGameEdition, + inGameEditorResources); + + // Save the project to JSON + gd::String output = + "gdjs.projectData = " + gd::Serializer::ToJSON(projectDataElement) + + ";\ngdjs.runtimeGameOptions = " + gd::Serializer::ToJSON(runtimeGameOptions) + + ";\n"; + + if (!fs.WriteToFile(filename, output)) + return "Unable to write " + filename; + + return ""; +} + +void ExporterHelper::SerializeRuntimeGameOptions( + gd::AbstractFileSystem &fs, const gd::String &gdjsRoot, + const PreviewExportOptions &options, std::vector &includesFiles, + gd::SerializerElement &runtimeGameOptions) { // 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); + initialRuntimeGameStatus.AddChild("editorId").SetValue(options.editorId); + if (!options.editorCamera3DCameraMode.empty()) { + auto &editorCamera3D = + initialRuntimeGameStatus.AddChild("editorCamera3D"); + editorCamera3D.AddChild("cameraMode").SetStringValue( + options.editorCamera3DCameraMode); + editorCamera3D.AddChild("positionX") + .SetDoubleValue(options.editorCamera3DPositionX); + editorCamera3D.AddChild("positionY") + .SetDoubleValue(options.editorCamera3DPositionY); + editorCamera3D.AddChild("positionZ") + .SetDoubleValue(options.editorCamera3DPositionZ); + editorCamera3D.AddChild("rotationAngle") + .SetDoubleValue(options.editorCamera3DRotationAngle); + editorCamera3D.AddChild("elevationAngle") + .SetDoubleValue(options.editorCamera3DElevationAngle); + editorCamera3D.AddChild("distance") + .SetDoubleValue(options.editorCamera3DDistance); + } } - runtimeGameOptions.AddChild("projectDataOnlyExport") - .SetBoolValue(options.projectDataOnlyExport); + if (!options.externalLayoutName.empty()) { + initialRuntimeGameStatus.AddChild("injectedExternalLayoutName") + .SetValue(options.externalLayoutName); + + if (options.isInGameEdition) { + initialRuntimeGameStatus.AddChild("skipCreatingInstancesFromScene") + .SetBoolValue(true); + } + } + if (!options.eventsBasedObjectType.empty()) { + initialRuntimeGameStatus.AddChild("eventsBasedObjectType") + .SetValue(options.eventsBasedObjectType); + initialRuntimeGameStatus.AddChild("eventsBasedObjectVariantName") + .SetValue(options.eventsBasedObjectVariantName); + } + + if (!options.inGameEditorSettingsJson.empty()) { + runtimeGameOptions.AddChild("inGameEditorSettings") = + gd::Serializer::FromJSON(options.inGameEditorSettingsJson); + } + + runtimeGameOptions.AddChild("shouldReloadLibraries") + .SetBoolValue(options.shouldReloadLibraries); + runtimeGameOptions.AddChild("shouldGenerateScenesEventsCode") + .SetBoolValue(options.shouldGenerateScenesEventsCode); + runtimeGameOptions.AddChild("nativeMobileApp") .SetBoolValue(options.nativeMobileApp); runtimeGameOptions.AddChild("websocketDebuggerServerAddress") @@ -297,71 +497,133 @@ bool ExporterHelper::ExportProjectForPixiPreview( for (const auto &includeFile : includesFiles) { auto hashIt = options.includeFileHashes.find(includeFile); - gd::String scriptSrc = GetExportedIncludeFilename(includeFile); + gd::String scriptSrc = GetExportedIncludeFilename(fs, gdjsRoot, includeFile); scriptFilesElement.AddChild("scriptFile") .SetStringAttribute("path", scriptSrc) .SetIntAttribute( "hash", hashIt != options.includeFileHashes.end() ? hashIt->second : 0); } - - // Export the project - ExportProjectData(fs, - exportedProject, - codeOutputDir + "/data.js", - runtimeGameOptions, - projectUsedResources, - scenesUsedResources); - includesFiles.push_back(codeOutputDir + "/data.js"); - - previousTime = LogTimeSpent("Project data export", previousTime); - - // Copy all the dependencies and their source maps - ExportIncludesAndLibs(includesFiles, options.exportPath, true); - ExportIncludesAndLibs(resourcesFiles, options.exportPath, true); - - // Create the index file - if (!ExportIndexFile(exportedProject, - gdjsRoot + "/Runtime/index.html", - options.exportPath, - includesFiles, - usedExtensionsResult.GetUsedSourceFiles(), - options.nonRuntimeScriptsCacheBurst, - "gdjs.runtimeGameOptions")) - return false; - - previousTime = LogTimeSpent("Include and libs export", previousTime); - return true; } -gd::String ExporterHelper::ExportProjectData( - gd::AbstractFileSystem &fs, +void ExporterHelper::AddInGameEditorResources( gd::Project &project, - gd::String filename, - const gd::SerializerElement &runtimeGameOptions, std::set &projectUsedResources, - std::unordered_map> &scenesUsedResources) { - fs.MkDir(fs.DirNameFrom(filename)); + const std::vector &inGameEditorResources) { + for (const auto &inGameEditorResource : inGameEditorResources) { + project.GetResourcesManager().AddResource( + inGameEditorResource.GetResourceName(), + inGameEditorResource.GetFilePath(), + inGameEditorResource.GetKind()); + projectUsedResources.insert(inGameEditorResource.GetResourceName()); + } +} + +void ExporterHelper::SerializeProjectData(gd::AbstractFileSystem &fs, + const gd::Project &project, + const PreviewExportOptions &options, + gd::SerializerElement &rootElement, + const std::vector &inGameEditorResources) { + gd::Project clonedProject = project; + + // Replace all resource file paths with the one used in exported projects. + auto projectDirectory = fs.DirNameFrom(project.GetProjectFile()); + gd::ResourcesMergingHelper resourcesMergingHelper( + clonedProject.GetResourcesManager(), fs); + resourcesMergingHelper.SetBaseDirectory(projectDirectory); + if (options.isInGameEdition) { + resourcesMergingHelper.SetShouldUseOriginalAbsoluteFilenames(); + } else { + resourcesMergingHelper.PreserveDirectoriesStructure(false); + resourcesMergingHelper.PreserveAbsoluteFilenames(false); + } + gd::ResourceExposer::ExposeWholeProjectResources(clonedProject, + resourcesMergingHelper); + + ExporterHelper::StriptAndSerializeProjectData(clonedProject, rootElement, + options.isInGameEdition, + inGameEditorResources); +} + +void ExporterHelper::StriptAndSerializeProjectData( + gd::Project &project, gd::SerializerElement &rootElement, + bool isInGameEdition, + const std::vector &inGameEditorResources) { + auto projectUsedResources = + gd::SceneResourcesFinder::FindProjectResources(project); + + if (isInGameEdition) { + // All used in-game editor resources must be always loaded and available. + ExporterHelper::AddInGameEditorResources( + project, projectUsedResources, inGameEditorResources); + } + + std::unordered_map> scenesUsedResources; + for (std::size_t layoutIndex = 0; + layoutIndex < project.GetLayoutsCount(); layoutIndex++) { + auto &layout = project.GetLayout(layoutIndex); + scenesUsedResources[layout.GetName()] = + gd::SceneResourcesFinder::FindSceneResources(project, layout); + } + + std::unordered_map> + eventsBasedObjectVariantsUsedResources; + for (std::size_t extensionIndex = 0; + extensionIndex < project.GetEventsFunctionsExtensionsCount(); + extensionIndex++) { + auto &eventsFunctionsExtension = + project.GetEventsFunctionsExtension(extensionIndex); + for (auto &&eventsBasedObject : + eventsFunctionsExtension.GetEventsBasedObjects().GetInternalVector()) { + + auto eventsBasedObjectType = gd::PlatformExtension::GetObjectFullType( + eventsFunctionsExtension.GetName(), eventsBasedObject->GetName()); + eventsBasedObjectVariantsUsedResources[eventsBasedObjectType] = + gd::SceneResourcesFinder::FindEventsBasedObjectVariantResources( + project, eventsBasedObject->GetDefaultVariant()); + + for (auto &&eventsBasedObjectVariant : + eventsBasedObject->GetVariants().GetInternalVector()) { + + auto variantType = gd::PlatformExtension::GetVariantFullType( + eventsFunctionsExtension.GetName(), eventsBasedObject->GetName(), + eventsBasedObjectVariant->GetName()); + eventsBasedObjectVariantsUsedResources[variantType] = + gd::SceneResourcesFinder::FindEventsBasedObjectVariantResources( + project, *eventsBasedObjectVariant); + } + } + } + + // Strip the project (*after* generating events as the events may use stripped + // things (objects groups...)) + gd::ProjectStripper::StripProjectForExport(project); - // Save the project to JSON - gd::SerializerElement rootElement; project.SerializeTo(rootElement); - SerializeUsedResources( - rootElement, projectUsedResources, scenesUsedResources); - gd::String output = - "gdjs.projectData = " + gd::Serializer::ToJSON(rootElement) + ";\n" + - "gdjs.runtimeGameOptions = " + - gd::Serializer::ToJSON(runtimeGameOptions) + ";\n"; - - if (!fs.WriteToFile(filename, output)) return "Unable to write " + filename; - - return ""; + SerializeUsedResources(rootElement, projectUsedResources, scenesUsedResources, + eventsBasedObjectVariantsUsedResources); + if (isInGameEdition) { + auto &behaviorsElement = rootElement.AddChild("activatedByDefaultInEditorBehaviors"); + behaviorsElement.ConsiderAsArrayOf("resourceReference"); + auto &platform = project.GetCurrentPlatform(); + for (auto &extension : platform.GetAllPlatformExtensions()) { + for (auto &behaviorType : extension->GetBehaviorsTypes()) { + auto &behaviorMetadata = extension->GetBehaviorMetadata(behaviorType); + if (behaviorMetadata.IsActivatedByDefaultInEditor()) { + behaviorsElement.AddChild("resourceReference") + .SetStringValue(behaviorType); + } + } + } + } } void ExporterHelper::SerializeUsedResources( gd::SerializerElement &rootElement, std::set &projectUsedResources, - std::unordered_map> &scenesUsedResources) { + std::unordered_map> &scenesUsedResources, + std::unordered_map> + &eventsBasedObjectVariantsUsedResources) { auto serializeUsedResources = [](gd::SerializerElement &element, std::set &usedResources) -> void { @@ -385,6 +647,41 @@ void ExporterHelper::SerializeUsedResources( auto &layoutUsedResources = scenesUsedResources[layoutName]; serializeUsedResources(layoutElement, layoutUsedResources); } + + auto &extensionsElement = rootElement.GetChild("eventsFunctionsExtensions"); + for (std::size_t extensionIndex = 0; + extensionIndex < extensionsElement.GetChildrenCount(); + extensionIndex++) { + auto &extensionElement = extensionsElement.GetChild(extensionIndex); + const auto extensionName = extensionElement.GetStringAttribute("name"); + + auto &objectsElement = extensionElement.GetChild("eventsBasedObjects"); + + for (std::size_t objectIndex = 0; + objectIndex < objectsElement.GetChildrenCount(); objectIndex++) { + auto &objectElement = objectsElement.GetChild(objectIndex); + const auto objectName = objectElement.GetStringAttribute("name"); + + auto eventsBasedObjectType = + gd::PlatformExtension::GetObjectFullType(extensionName, objectName); + auto &objectUsedResources = + eventsBasedObjectVariantsUsedResources[eventsBasedObjectType]; + serializeUsedResources(objectElement, objectUsedResources); + + auto &variantsElement = objectElement.GetChild("variants"); + for (std::size_t variantIndex = 0; + variantIndex < variantsElement.GetChildrenCount(); variantIndex++) { + auto &variantElement = variantsElement.GetChild(variantIndex); + const auto variantName = variantElement.GetStringAttribute("name"); + + auto variantType = gd::PlatformExtension::GetVariantFullType( + extensionName, objectName, variantName); + auto &variantUsedResources = + eventsBasedObjectVariantsUsedResources[variantType]; + serializeUsedResources(variantElement, variantUsedResources); + } + } + } } bool ExporterHelper::ExportIndexFile( @@ -775,7 +1072,7 @@ bool ExporterHelper::CompleteIndexFile( gd::String codeFilesIncludes; for (auto &include : includesFiles) { gd::String scriptSrc = - GetExportedIncludeFilename(include, nonRuntimeScriptsCacheBurst); + GetExportedIncludeFilename(fs, gdjsRoot, include, nonRuntimeScriptsCacheBurst); // Sanity check if the file exists - if not skip it to avoid // including it in the list of scripts. @@ -801,6 +1098,7 @@ bool ExporterHelper::CompleteIndexFile( void ExporterHelper::AddLibsInclude(bool pixiRenderers, bool pixiInThreeRenderers, + bool isInGameEdition, bool includeWebsocketDebuggerClient, bool includeWindowMessageDebuggerClient, bool includeMinimalDebuggerClient, @@ -890,14 +1188,21 @@ void ExporterHelper::AddLibsInclude(bool pixiRenderers, InsertUnique(includesFiles, "debugger-client/minimal-debugger-client.js"); } - if (pixiInThreeRenderers) { + if (pixiInThreeRenderers || isInGameEdition) { InsertUnique(includesFiles, "pixi-renderers/three.js"); InsertUnique(includesFiles, "pixi-renderers/ThreeAddons.js"); InsertUnique(includesFiles, "pixi-renderers/draco/gltf/draco_decoder.wasm"); InsertUnique(includesFiles, "pixi-renderers/draco/gltf/draco_wasm_wrapper.js"); + // Extensions in JS may use it. + InsertUnique(includesFiles, "Extensions/3D/Scene3DTools.js"); + InsertUnique(includesFiles, "Extensions/3D/A_RuntimeObject3D.js"); + InsertUnique(includesFiles, "Extensions/3D/A_RuntimeObject3DRenderer.js"); + InsertUnique(includesFiles, "Extensions/3D/CustomRuntimeObject3D.js"); + InsertUnique(includesFiles, + "Extensions/3D/CustomRuntimeObject3DRenderer.js"); } - if (pixiRenderers) { + if (pixiRenderers || isInGameEdition) { InsertUnique(includesFiles, "pixi-renderers/pixi.js"); InsertUnique(includesFiles, "pixi-renderers/pixi-filters-tools.js"); InsertUnique(includesFiles, "pixi-renderers/runtimegame-pixi-renderer.js"); @@ -921,12 +1226,11 @@ void ExporterHelper::AddLibsInclude(bool pixiRenderers, includesFiles, "fontfaceobserver-font-manager/fontfaceobserver-font-manager.js"); } - if (pixiInThreeRenderers) { - InsertUnique(includesFiles, "Extensions/3D/A_RuntimeObject3D.js"); - InsertUnique(includesFiles, "Extensions/3D/A_RuntimeObject3DRenderer.js"); - InsertUnique(includesFiles, "Extensions/3D/CustomRuntimeObject3D.js"); - InsertUnique(includesFiles, - "Extensions/3D/CustomRuntimeObject3DRenderer.js"); + if (isInGameEdition) { + // `InGameEditor` uses the `is3D` function. + InsertUnique(includesFiles, "Extensions/3D/Base3DBehavior.js"); + InsertUnique(includesFiles, "Extensions/3D/HemisphereLight.js"); + InsertUnique(includesFiles, "InGameEditor/InGameEditor.js"); } if (includeCaptureManager) { InsertUnique(includesFiles, "capturemanager.js"); @@ -959,7 +1263,7 @@ bool ExporterHelper::ExportEffectIncludes( return true; } -bool ExporterHelper::ExportEventsCode( +bool ExporterHelper::ExportScenesEventsCode( const gd::Project &project, gd::String outputDir, std::vector &includesFiles, @@ -995,6 +1299,7 @@ bool ExporterHelper::ExportEventsCode( } gd::String ExporterHelper::GetExportedIncludeFilename( + gd::AbstractFileSystem &fs, const gd::String &gdjsRoot, const gd::String &include, unsigned int nonRuntimeScriptsCacheBurst) { auto addSearchParameterToUrl = [](const gd::String &url, const gd::String &urlEncodedParameterName, diff --git a/GDJS/GDJS/IDE/ExporterHelper.h b/GDJS/GDJS/IDE/ExporterHelper.h index ba910cdc14..f7344531ee 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.h +++ b/GDJS/GDJS/IDE/ExporterHelper.h @@ -24,6 +24,7 @@ class SourceFileMetadata; class WholeProjectDiagnosticReport; class CaptureOptions; class Screenshot; +class InGameEditorResourceMetadata; } // namespace gd namespace gdjs { @@ -42,9 +43,9 @@ struct PreviewExportOptions { useWindowMessageDebuggerClient(false), useMinimalDebuggerClient(false), nativeMobileApp(false), - projectDataOnlyExport(false), fullLoadingScreen(false), isDevelopmentEnvironment(false), + isInGameEdition(false), nonRuntimeScriptsCacheBurst(0), inAppTutorialMessageInPreview(""), inAppTutorialMessagePositionInPreview(""), @@ -145,6 +146,26 @@ struct PreviewExportOptions { return *this; } + /** + * \brief Set the (optional) events-based object to be instantiated in the scene + * at the beginning of the previewed game. + */ + PreviewExportOptions &SetEventsBasedObjectType( + const gd::String &eventsBasedObjectType_) { + eventsBasedObjectType = eventsBasedObjectType_; + return *this; + } + + /** + * \brief Set the (optional) events-based object variant to be instantiated in the scene + * at the beginning of the previewed game. + */ + PreviewExportOptions &SetEventsBasedObjectVariantName( + const gd::String &eventsBasedObjectVariantName_) { + eventsBasedObjectVariantName = eventsBasedObjectVariantName_; + return *this; + } + /** * \brief Set the hash associated to an include file. Useful for the preview * hot-reload, to know if a file changed. @@ -156,11 +177,34 @@ struct PreviewExportOptions { } /** - * \brief Set if the export should only export the project data, not - * exporting events code. + * \brief Set if the exported folder should be cleared befor the export. */ - PreviewExportOptions &SetProjectDataOnlyExport(bool enable) { - projectDataOnlyExport = enable; + PreviewExportOptions &SetShouldClearExportFolder(bool enable) { + shouldClearExportFolder = enable; + return *this; + } + + /** + * \brief Set if the `ProjectData` must be reloaded. + */ + PreviewExportOptions &SetShouldReloadProjectData(bool enable) { + shouldReloadProjectData = enable; + return *this; + } + + /** + * \brief Set if GDJS libraries must be reloaded. + */ + PreviewExportOptions &SetShouldReloadLibraries(bool enable) { + shouldReloadLibraries = enable; + return *this; + } + + /** + * \brief Set if the export should export events code. + */ + PreviewExportOptions &SetShouldGenerateScenesEventsCode(bool enable) { + shouldGenerateScenesEventsCode = enable; return *this; } @@ -182,6 +226,48 @@ 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 Set the JSON string representation of the in-game editor settings. + */ + PreviewExportOptions &SetInGameEditorSettingsJson(const gd::String &inGameEditorSettingsJson_) { + inGameEditorSettingsJson = inGameEditorSettingsJson_; + return *this; + } + + /** + * \brief Set the in-game editor identifier. + */ + PreviewExportOptions &SetEditorId(const gd::String &editorId_) { + editorId = editorId_; + return *this; + } + + /** + * \brief Set the camera state to use in the in-game editor. + */ + PreviewExportOptions & + SetEditorCameraState3D(const gd::String &cameraMode, double positionX, + double positionY, double positionZ, + double rotationAngle, double elevationAngle, + double distance) { + editorCamera3DCameraMode = cameraMode; + editorCamera3DPositionX = positionX; + editorCamera3DPositionY = positionY; + editorCamera3DPositionZ = positionZ; + editorCamera3DRotationAngle = rotationAngle; + editorCamera3DElevationAngle = elevationAngle; + editorCamera3DDistance = distance; + 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 @@ -294,6 +380,8 @@ struct PreviewExportOptions { bool useMinimalDebuggerClient; gd::String layoutName; gd::String externalLayoutName; + gd::String eventsBasedObjectType; + gd::String eventsBasedObjectVariantName; gd::String fallbackAuthorUsername; gd::String fallbackAuthorId; gd::String playerId; @@ -303,9 +391,22 @@ struct PreviewExportOptions { gd::String inAppTutorialMessagePositionInPreview; bool nativeMobileApp; std::map includeFileHashes; - bool projectDataOnlyExport; + bool shouldClearExportFolder = true; + bool shouldReloadProjectData = true; + bool shouldReloadLibraries = true; + bool shouldGenerateScenesEventsCode = true; bool fullLoadingScreen; bool isDevelopmentEnvironment; + bool isInGameEdition; + gd::String editorId; + gd::String editorCamera3DCameraMode; + gd::String inGameEditorSettingsJson; + double editorCamera3DPositionX = 0; + double editorCamera3DPositionY = 0; + double editorCamera3DPositionZ = 0; + double editorCamera3DRotationAngle = 0; + double editorCamera3DElevationAngle = 0; + double editorCamera3DDistance = 0; unsigned int nonRuntimeScriptsCacheBurst; gd::String electronRemoteRequirePath; gd::String gdevelopResourceToken; @@ -379,23 +480,54 @@ class ExporterHelper { const gd::String &GetLastError() const { return lastError; }; /** - * \brief Export a project to JSON + * \brief Export a project without its events and options to 2 JS variables * * \param fs The abstract file system to use to write the file * \param project The project to be exported. * \param filename The filename where export the project * \param runtimeGameOptions The content of the extra configuration to store - * in gdjs.runtimeGameOptions \return Empty string if everything is ok, + * in gdjs.runtimeGameOptions + * + * \return Empty string if everything is ok, * description of the error otherwise. */ static gd::String ExportProjectData( - gd::AbstractFileSystem &fs, - gd::Project &project, - gd::String filename, - const gd::SerializerElement &runtimeGameOptions, - std::set &projectUsedResources, - std::unordered_map> - &layersUsedResources); + gd::AbstractFileSystem &fs, gd::Project &project, gd::String filename, + const gd::SerializerElement &runtimeGameOptions, bool isInGameEdition, + const std::vector &inGameEditorResources); + + /** + * \brief Serialize a project without its events to JSON + * + * \param fs The abstract file system to use to write the file + * \param project The project to be exported. + * \param options The content of the extra configuration + * \param projectDataElement The element where the project data is serialized + * \param inGameEditorResources The list of in-game editor resources to be used. + */ + static void SerializeProjectData(gd::AbstractFileSystem &fs, + const gd::Project &project, + const PreviewExportOptions &options, + gd::SerializerElement &projectDataElement, + const std::vector &inGameEditorResources); + + /** + * \brief Serialize the content of the extra configuration to store + * in gdjs.runtimeGameOptions to JSON + * + * \param fs The abstract file system to use to write the file + * \param gdjsRoot The root directory of GDJS, used to copy runtime files. + * \param options The content of the extra configuration + * \param includesFiles The list of scripts files - useful for hot-reloading + * \param runtimeGameOptionsElement The element where the game options are + * serialized + */ + static void + SerializeRuntimeGameOptions(gd::AbstractFileSystem &fs, + const gd::String &gdjsRoot, + const PreviewExportOptions &options, + std::vector &includesFiles, + gd::SerializerElement &runtimeGameOptionsElement); /** * \brief Copy all the resources of the project to to the export directory, @@ -416,6 +548,7 @@ class ExporterHelper { */ void AddLibsInclude(bool pixiRenderers, bool pixiInThreeRenderers, + bool isInGameEdition, bool includeWebsocketDebuggerClient, bool includeWindowMessageDebuggerClient, bool includeMinimalDebuggerClient, @@ -453,7 +586,7 @@ class ExporterHelper { * includesFiles A reference to a vector that will be filled with JS files to * be exported along with the project. ( including "codeX.js" files ). */ - bool ExportEventsCode( + bool ExportScenesEventsCode( const gd::Project &project, gd::String outputDir, std::vector &includesFiles, @@ -578,14 +711,20 @@ class ExporterHelper { * a browser pointing to the preview. * * \param options The options to generate the preview. + * \param includesFiles The list of scripts files - useful for hot-reloading */ - bool ExportProjectForPixiPreview(const PreviewExportOptions &options); + bool ExportProjectForPixiPreview(const PreviewExportOptions &options, + std::vector &includesFiles); /** * \brief Given an include file, returns the name of the file to reference * in the exported game. + * + * \param fs The abstract file system to use + * \param gdjsRoot The root directory of GDJS, used to copy runtime files. */ - gd::String GetExportedIncludeFilename( + static gd::String GetExportedIncludeFilename( + gd::AbstractFileSystem &fs, const gd::String &gdjsRoot, const gd::String &include, unsigned int nonRuntimeScriptsCacheBurst = 0); /** @@ -612,11 +751,30 @@ class ExporterHelper { ///< be then copied to the final output directory. private: - static void SerializeUsedResources( - gd::SerializerElement &rootElement, - std::set &projectUsedResources, - std::unordered_map> - &layersUsedResources); + static void + SerializeUsedResources(gd::SerializerElement &rootElement, + std::set &projectUsedResources, + std::unordered_map> + &layersUsedResources, + std::unordered_map> + &eventsBasedObjectVariantsUsedResources); + + /** + * \brief Strip a project and serialize it to JSON. + */ + static void StriptAndSerializeProjectData(gd::Project &project, + gd::SerializerElement &rootElement, + bool isInGameEdition, + const std::vector &inGameEditorResources); + + /** + * \brief Add additional resources that are used by the in-game editor to the + * project. + */ + static void + AddInGameEditorResources(gd::Project &project, + std::set &projectUsedResources, + const std::vector &inGameEditorResources); }; } // namespace gdjs diff --git a/GDJS/Runtime/CustomRuntimeObject.ts b/GDJS/Runtime/CustomRuntimeObject.ts index 828a880ba5..d5e65de965 100644 --- a/GDJS/Runtime/CustomRuntimeObject.ts +++ b/GDJS/Runtime/CustomRuntimeObject.ts @@ -13,7 +13,7 @@ namespace gdjs { export type CustomObjectConfiguration = ObjectConfiguration & { animatable?: SpriteAnimationData[]; variant: string; - childrenContent: { [objectName: string]: ObjectConfiguration & any }; + childrenContent?: { [objectName: string]: ObjectConfiguration & any }; isInnerAreaFollowingParentSize: boolean; }; @@ -118,37 +118,19 @@ namespace gdjs { ); return; } - - if (!eventsBasedObjectData.defaultVariant) { - eventsBasedObjectData.defaultVariant = { - ...eventsBasedObjectData, - name: '', - }; - } - // Legacy events-based objects don't have any instance in their default - // variant since there wasn't a graphical editor at the time. - // In this case, the editor doesn't allow to choose a variant, but a - // variant may have stayed after a user rolled back the extension. - // This variant must be ignored to match what the editor shows. - const isForcedToOverrideEventsBasedObjectChildrenConfiguration = - eventsBasedObjectData.defaultVariant.instances.length == 0; - let usedVariantData: EventsBasedObjectVariantData = - eventsBasedObjectData.defaultVariant; - if ( - customObjectData.variant && - !isForcedToOverrideEventsBasedObjectChildrenConfiguration - ) { - for ( - let variantIndex = 0; - variantIndex < eventsBasedObjectData.variants.length; - variantIndex++ - ) { - const variantData = eventsBasedObjectData.variants[variantIndex]; - if (variantData.name === customObjectData.variant) { - usedVariantData = variantData; - break; - } - } + const usedVariantData: EventsBasedObjectVariantData | null = + this.getRuntimeScene() + .getGame() + .getEventsBasedObjectVariantData( + customObjectData.type, + customObjectData.variant + ); + if (!usedVariantData) { + // This can't actually happen. + logger.error( + `Unknown variant "${customObjectData.variant}" for object "${customObjectData.type}".` + ); + return; } this._isInnerAreaFollowingParentSize = @@ -178,8 +160,7 @@ namespace gdjs { override reinitialize(objectData: ObjectData & CustomObjectConfiguration) { super.reinitialize(objectData); - this._reinitializeRenderer(); - this._initializeFromObjectData(objectData); + this._reinitializeContentFromObjectData(objectData); // When changing the variant, the instance is like a new instance. // We call again `onCreated` at the end, like done by the constructor @@ -187,6 +168,14 @@ namespace gdjs { this.onCreated(); } + private _reinitializeContentFromObjectData( + objectData: ObjectData & CustomObjectConfiguration + ) { + this._reinitializeRenderer(); + this._instanceContainer._unloadContent(); + this._initializeFromObjectData(objectData); + } + override updateFromObjectData( oldObjectData: ObjectData & CustomObjectConfiguration, newObjectData: ObjectData & CustomObjectConfiguration @@ -214,8 +203,7 @@ namespace gdjs { this._instanceContainer._initialInnerArea.max[1] !== this._innerArea.max[1]); - this._reinitializeRenderer(); - this._initializeFromObjectData(newObjectData); + this._reinitializeContentFromObjectData(newObjectData); // The generated code calls the onCreated super implementation at the end. this.onCreated(); @@ -311,15 +299,13 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } - if (initialInstanceData.flippedX) { - this.flipX(initialInstanceData.flippedX); - } - if (initialInstanceData.flippedY) { - this.flipY(initialInstanceData.flippedY); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); + this.flipX(!!initialInstanceData.flippedX); + this.flipY(!!initialInstanceData.flippedY); } override onDeletedFromScene(): void { @@ -658,6 +644,20 @@ namespace gdjs { return this._unrotatedAABB.max[1]; } + getOriginalWidth(): float { + return ( + this._instanceContainer.getInitialUnrotatedViewportMaxX() - + this._instanceContainer.getInitialUnrotatedViewportMinX() + ); + } + + getOriginalHeight(): float { + return ( + this._instanceContainer.getInitialUnrotatedViewportMaxY() - + this._instanceContainer.getInitialUnrotatedViewportMinY() + ); + } + /** * @return the internal width of the object according to its children. */ diff --git a/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts b/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts index d960c092f0..bb1a52fe61 100644 --- a/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts +++ b/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts @@ -16,6 +16,7 @@ namespace gdjs { _parent: gdjs.RuntimeInstanceContainer; /** The object that is built with the instances of this container. */ _customObject: gdjs.CustomRuntimeObject; + // TODO Remove this attribute _isLoaded: boolean = false; /** * The default size defined by users in the custom object initial instances editor. @@ -39,22 +40,35 @@ namespace gdjs { parent: gdjs.RuntimeInstanceContainer, customObject: gdjs.CustomRuntimeObject ) { - super(); + super(parent.getGame()); this._parent = parent; this._customObject = customObject; this._runtimeScene = parent.getScene(); this._debuggerRenderer = new gdjs.DebuggerRenderer(this); } + // TODO `_layers` and `_orderedLayers` should not be used directly. + addLayer(layerData: LayerData) { if (this._layers.containsKey(layerData.name)) { return; } + // This code is duplicated with `RuntimeScene.addLayer` because it avoids + // to expose a method to build a layer. const layer = new gdjs.RuntimeCustomObjectLayer(layerData, this); this._layers.put(layerData.name, layer); this._orderedLayers.push(layer); } + _unloadContent() { + this.onDeletedFromScene(this._parent); + // At this point, layer renderers are already removed by + // `CustomRuntimeObject._reinitializeRenderer`. + // It's not great to do this here, but it allows to keep it private. + this._layers.clear(); + this._orderedLayers.length = 0; + } + createObject(objectName: string): gdjs.RuntimeObject | null { const result = super.createObject(objectName); this._customObject.onChildrenLocationChanged(); @@ -63,21 +77,14 @@ namespace gdjs { /** * Load the container from the given initial configuration. - * @param customObjectData An object containing the container data. + * @param customObjectData An object containing the parent object data. + * @param eventsBasedObjectVariantData An object containing the container data. * @see gdjs.RuntimeGame#getSceneAndExtensionsData */ loadFrom( customObjectData: ObjectData & CustomObjectConfiguration, eventsBasedObjectVariantData: EventsBasedObjectVariantData ) { - if (this._isLoaded) { - this.onDeletedFromScene(this._parent); - } - - const isForcedToOverrideEventsBasedObjectChildrenConfiguration = - !eventsBasedObjectVariantData.name && - eventsBasedObjectVariantData.instances.length == 0; - this._setOriginalInnerArea(eventsBasedObjectVariantData); // Registering objects @@ -87,19 +94,21 @@ namespace gdjs { ++i ) { const childObjectData = eventsBasedObjectVariantData.objects[i]; - // The children configuration override only applies to the default variant. if ( customObjectData.childrenContent && - (!eventsBasedObjectVariantData.name || - isForcedToOverrideEventsBasedObjectChildrenConfiguration) + gdjs.CustomRuntimeObjectInstanceContainer.hasChildrenConfigurationOverriding( + customObjectData, + eventsBasedObjectVariantData + ) ) { this.registerObject({ ...childObjectData, - // The custom object overrides its events-based object configuration. + // The custom object overrides its variant configuration with + // a legacy children configuration. ...customObjectData.childrenContent[childObjectData.name], }); } else { - // The custom object follows its events-based object configuration. + // The custom object follows its variant configuration. this.registerObject(childObjectData); } } @@ -154,6 +163,28 @@ namespace gdjs { this._isLoaded = true; } + /** + * Check if the custom object has a children configuration overriding that + * should be used instead of the variant's objects configurations. + * @param customObjectData An object containing the parent object data. + * @param eventsBasedObjectVariantData An object containing the container data. + * @returns + */ + static hasChildrenConfigurationOverriding( + customObjectData: CustomObjectConfiguration, + eventsBasedObjectVariantData: EventsBasedObjectVariantData + ): boolean { + const isForcedToOverrideEventsBasedObjectChildrenConfiguration = + !eventsBasedObjectVariantData.name && + eventsBasedObjectVariantData.instances.length == 0; + + // The children configuration override only applies to the default variant. + return customObjectData.childrenContent + ? !eventsBasedObjectVariantData.name || + isForcedToOverrideEventsBasedObjectChildrenConfiguration + : false; + } + /** * Initialize `_initialInnerArea` if it doesn't exist. * `_initialInnerArea` is shared by every instance to save memory. @@ -161,7 +192,10 @@ namespace gdjs { private _setOriginalInnerArea( eventsBasedObjectData: EventsBasedObjectVariantData ) { - if (eventsBasedObjectData.instances.length > 0) { + if ( + eventsBasedObjectData.instances.length > 0 || + this.getGame().isInGameEdition() + ) { if (!eventsBasedObjectData._initialInnerArea) { eventsBasedObjectData._initialInnerArea = { min: [ @@ -341,6 +375,12 @@ namespace gdjs { return this._initialInnerArea ? this._initialInnerArea.max[1] : 0; } + _getInitialInnerAreaDepth(): float { + return this._initialInnerArea + ? this._initialInnerArea.max[2] - this._initialInnerArea.min[2] + : 0; + } + getViewportWidth(): float { return this._customObject.getUnscaledWidth(); } diff --git a/GDJS/Runtime/InGameEditor/FocusIcon.svg b/GDJS/Runtime/InGameEditor/FocusIcon.svg new file mode 100644 index 0000000000..d304a6a07c --- /dev/null +++ b/GDJS/Runtime/InGameEditor/FocusIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/GDJS/Runtime/InGameEditor/FreeCameraIcon.svg b/GDJS/Runtime/InGameEditor/FreeCameraIcon.svg new file mode 100644 index 0000000000..db2dba8714 --- /dev/null +++ b/GDJS/Runtime/InGameEditor/FreeCameraIcon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx new file mode 100644 index 0000000000..ed36eda040 --- /dev/null +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -0,0 +1,4998 @@ +namespace gdjs { + const logger = new gdjs.Logger('In-Game editor'); + + /** + * A minimal utility to define DOM elements. + * Also copied in InGameDebugger.tsx. + */ + function h( + tag: K, + attrs: { + style?: Partial; + onClick?: () => void; + }, + ...nodes: (HTMLElement | string)[] + ): HTMLElement { + const node = document.createElement(tag); + Object.keys(attrs).forEach((key) => { + if (key === 'style') { + for (const [styleName, value] of Object.entries(attrs.style!)) { + node.style[styleName] = value; + } + } else if (key === 'onClick') { + node.addEventListener('click', attrs[key]!); + } else { + node.setAttribute(key, '' + attrs[key]); + } + }); + + node.append(...nodes); + return node; + } + + /** + * Adapt the Three.js TransformControls gizmos so that the axis are in the same direction as in GDevelop. + * This does not change the way the controls work (notably when dragged), it's only a visual adaptation. + */ + const patchAxesOnTransformControlsGizmos = ( + controls: THREE_ADDONS.TransformControls + ) => { + const gizmoRoot = controls && (controls as any)._gizmo; + if (!gizmoRoot) return; + + // Flip gizmo (visual), picker (raycast), and helper (guide lines) + const groupsToFlip = ['gizmo', 'picker', 'helper']; + + // Bake axis reflections into geometry so per-frame handle scaling + // inside TransformControlsGizmo.updateMatrixWorld() won't undo it. + const flipY = new THREE.Matrix4().makeScale(1, -1, 1); + const flipX = new THREE.Matrix4().makeScale(-1, 1, 1); + + // For translate mode: flip Y-axis handles + const shouldFlipYByName = (name) => + name === 'Y' || name === 'XY' || name === 'YZ'; + + // For scale mode: flip X-axis handles + const shouldFlipXByName = (name) => + name === 'X' || name === 'XY' || name === 'XZ'; + + // Process translate mode (flip Y) + for (const group of groupsToFlip) { + const root = gizmoRoot[group] && gizmoRoot[group]['translate']; + if (!root) continue; + + root.traverse((obj) => { + if (!obj || !obj.geometry) return; + const name = obj.name || ''; + if (!shouldFlipYByName(name)) return; + + // Bake the Y flip directly into the geometry + obj.geometry.applyMatrix4(flipY); + + // Keep raycasting bounds correct after mutation + if (typeof obj.geometry.computeBoundingBox === 'function') { + obj.geometry.computeBoundingBox(); + } + if (typeof obj.geometry.computeBoundingSphere === 'function') { + obj.geometry.computeBoundingSphere(); + } + }); + } + + // Process scale mode (flip X) + for (const group of groupsToFlip) { + const root = gizmoRoot[group] && gizmoRoot[group]['scale']; + if (!root) continue; + + root.traverse((obj) => { + if (!obj || !obj.geometry) return; + const name = obj.name || ''; + if (!shouldFlipXByName(name)) return; + + // Bake the X flip directly into the geometry + obj.geometry.applyMatrix4(flipX); + + // Keep raycasting bounds correct after mutation + if (typeof obj.geometry.computeBoundingBox === 'function') { + obj.geometry.computeBoundingBox(); + } + if (typeof obj.geometry.computeBoundingSphere === 'function') { + obj.geometry.computeBoundingSphere(); + } + }); + } + }; + + const patchColorsOnTransformControlsGizmos = ( + controls: THREE_ADDONS.TransformControls + ) => { + const gizmoRoot = controls && (controls as any)._gizmo; + if (!gizmoRoot) return; + + const colorMap = { + x: 0xf53e63, + y: 0xa4e507, + z: 0x36a9f5, + e: 0xffff00, + xyz: 0xffffff, + xyze: 0x787878, + highlight: 0xeeeeee, + }; + const modes = ['translate', 'rotate', 'scale']; + const groups = ['gizmo', 'helper']; + + // Helper to determine which color to use based on handle name + const getColorForHandle = (name) => { + const nameLower = (name || '').toLowerCase(); + + if (nameLower === 'xyze') return colorMap.xyze; + if (nameLower === 'xyz') return colorMap.xyz; + if (nameLower === 'e') return colorMap.e; + + // For plane handles (XY, YZ, XZ), use the color of the missing axis + if (nameLower === 'xy') return colorMap.z; + if (nameLower === 'yz') return colorMap.x; + if (nameLower === 'xz') return colorMap.y; + + // Single axis handles + if (nameLower === 'x' || nameLower.includes('x')) return colorMap.x; + if (nameLower === 'y' || nameLower.includes('y')) return colorMap.y; + if (nameLower === 'z' || nameLower.includes('z')) return colorMap.z; + + return null; + }; + + // Apply colors to all gizmo materials + for (const mode of modes) { + for (const group of groups) { + const root = gizmoRoot[group] && gizmoRoot[group][mode]; + if (!root) continue; + + root.traverse((obj) => { + if (!obj || !obj.material) return; + + const color = getColorForHandle(obj.name); + if (color === null) return; + + // Update the material color and store it as the base color + obj.material.color.setHex(color); + obj.material._color = obj.material.color.clone(); + obj.material._opacity = obj.material.opacity; + }); + } + } + + // Patch the gizmo's updateMatrixWorld to use custom highlight color + if (!gizmoRoot._originalUpdateMatrixWorld) { + gizmoRoot._originalUpdateMatrixWorld = + gizmoRoot.updateMatrixWorld.bind(gizmoRoot); + gizmoRoot._customHighlightColor = colorMap.highlight; + + gizmoRoot.updateMatrixWorld = function (force) { + // Call original update first + this._originalUpdateMatrixWorld(force); + + // Override the highlight color if an axis is selected + if (this.enabled && this.axis) { + const modes = ['translate', 'rotate', 'scale']; + const groups = ['gizmo', 'helper']; + + for (const mode of modes) { + for (const group of groups) { + const root = this[group] && this[group][mode]; + if (!root) continue; + + root.traverse((obj) => { + if (!obj || !obj.material) return; + + // Check if this handle should be highlighted + const shouldHighlight = + obj.name === this.axis || + this.axis.split('').some((a) => obj.name === a); + + if (shouldHighlight) { + // Apply custom highlight color + obj.material.color.setHex(this._customHighlightColor); + obj.material.opacity = 1.0; + } + }); + } + } + } + }; + } else { + // Update the stored highlight color if already patched + gizmoRoot._customHighlightColor = colorMap.highlight; + } + }; + + function patchNegativeAxisHandlesOnTransformControlsGizmos( + controls: THREE_ADDONS.TransformControls + ) { + const gizmo = (controls as any)._gizmo; + if (!gizmo) return; + + // Helper function to remove specific children from gizmo groups + function removeNegativeHandles(gizmoGroup, mode) { + if (!gizmoGroup || !gizmoGroup.children) return; + + const toRemove: Array = []; + const seenAxes = { X: [], Y: [], Z: [] }; + + // First pass: catalog all children by axis + gizmoGroup.children.forEach((child) => { + if (child.name === 'X' || child.name === 'Y' || child.name === 'Z') { + seenAxes[child.name].push(child); + } + }); + + // Second pass: mark negative handles for removal + Object.keys(seenAxes).forEach((axisName) => { + const axisChildren = seenAxes[axisName]; + + if (mode === 'translate' && axisChildren.length >= 2) { + // In translate: [positive arrow at index 0, negative arrow at index 1, line at index 2] + // Remove the negative arrow (index 1) + toRemove.push(axisChildren[1]); + } else if (mode === 'scale' && axisChildren.length >= 3) { + // In scale: [negative cube at index 0, line at index 1, positive cube at index 2] + // Remove the negative cube (index 2) + toRemove.push(axisChildren[0]); + } + }); + + // Remove marked children + toRemove.forEach((child) => { + gizmoGroup.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material && Array.isArray(child.material)) { + child.material.forEach((material) => material.dispose()); + } else if (child.material) { + child.material.dispose(); + } + }); + } + + // Only process the visual gizmos, NOT the pickers (which handle interaction) + if (gizmo.gizmo && gizmo.gizmo['translate']) { + removeNegativeHandles(gizmo.gizmo['translate'], 'translate'); + } + + if (gizmo.gizmo && gizmo.gizmo['scale']) { + removeNegativeHandles(gizmo.gizmo['scale'], 'scale'); + } + + // Keep the pickers intact for interaction - they're invisible anyway + } + + const getSvgIconUrl = (game: RuntimeGame, resourceName: string) => { + const resource = game.getResourceLoader().getResource(resourceName); + if (!resource) return ''; + return game.getResourceLoader().getFullUrl(resource.file); + }; + + const LEFT_KEY = 37; + const UP_KEY = 38; + const RIGHT_KEY = 39; + const DOWN_KEY = 40; + const ALT_KEY = 18; + const DEL_KEY = 46; + const BACKSPACE_KEY = 8; + const LEFT_ALT_KEY = gdjs.InputManager.getLocationAwareKeyCode(ALT_KEY, 1); + const RIGHT_ALT_KEY = gdjs.InputManager.getLocationAwareKeyCode(ALT_KEY, 2); + const SHIFT_KEY = 16; + const LEFT_SHIFT_KEY = gdjs.InputManager.getLocationAwareKeyCode( + SHIFT_KEY, + 1 + ); + const RIGHT_SHIFT_KEY = gdjs.InputManager.getLocationAwareKeyCode( + SHIFT_KEY, + 2 + ); + const SPACE_KEY = 32; + const CTRL_KEY = 17; + const LEFT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 1); + const RIGHT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 2); + const LEFT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(91, 1); + const RIGHT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(93, 2); + const W_KEY = 87; + const A_KEY = 65; + const C_KEY = 67; + const S_KEY = 83; + const D_KEY = 68; + const Q_KEY = 81; + const E_KEY = 69; + const F_KEY = 70; + const O_KEY = 79; + const V_KEY = 86; + const X_KEY = 88; + const Y_KEY = 89; + const Z_KEY = 90; + const ESC_KEY = 27; + const EQUAL_KEY = 187; + const MINUS_KEY = 189; + const KEY_DIGIT_1 = 49; + const KEY_DIGIT_2 = 50; + const KEY_DIGIT_3 = 51; + + const exceptionallyGetKeyCodeFromLocationAwareKeyCode = ( + locationAwareKeyCode: number + ): number => { + return locationAwareKeyCode % 1000; + }; + + // See same factors in `newIDE/app/src/Utils/ZoomUtils.js`. + const zoomInFactor = Math.pow(2, (2 * 1) / 16); + const zoomOutFactor = Math.pow(2, (-2 * 1) / 16); + + const instanceStateFlag = { + selected: 1, + locked: 2, + hovered: 4, + }; + const instanceWireframeColor = { + [instanceStateFlag.hovered]: '#54daff', + [instanceStateFlag.selected]: '#f2a63c', + [instanceStateFlag.selected | instanceStateFlag.hovered]: '#ffd200', + [instanceStateFlag.locked | instanceStateFlag.hovered]: '#b02715', + [instanceStateFlag.locked | instanceStateFlag.selected]: '#b87f7f', + [instanceStateFlag.locked | + instanceStateFlag.selected | + instanceStateFlag.hovered]: '#f51e02', + }; + + const editorCameraFov = 45; + + export type InGameEditorSettings = { + theme: { + iconButtonSelectedBackgroundColor: string; + iconButtonSelectedColor: string; + toolbarBackgroundColor: string; + toolbarSeparatorColor: string; + textColorPrimary: string; + }; + }; + + const defaultInGameEditorSettings: InGameEditorSettings = { + theme: { + iconButtonSelectedBackgroundColor: 'black', + iconButtonSelectedColor: 'black', + toolbarBackgroundColor: 'black', + toolbarSeparatorColor: 'black', + textColorPrimary: 'black', + }, + }; + + let hasWindowFocus = true; + if (typeof window !== 'undefined') { + window.addEventListener('focus', () => { + hasWindowFocus = true; + }); + window.addEventListener('blur', () => { + hasWindowFocus = false; + }); + } + + function isDefined(value: T | null | undefined): value is NonNullable { + return value !== null && value !== undefined; + } + + type Point3D = [float, float, float]; + + type RuntimeObjectWith3D = RuntimeObject & + Base3DHandler & + Resizable & + Scalable & + Flippable & { + getCenterZInScene(): float; + }; + + const is3D = (object: gdjs.RuntimeObject): object is RuntimeObjectWith3D => { + return gdjs.Base3DHandler.is3D(object); + }; + + type AABB3D = { + min: Point3D; + max: Point3D; + }; + + const defaultEffectsData: EffectData[] = [ + { + effectType: 'Scene3D::HemisphereLight', + name: 'Default Light for in-game editor', + doubleParameters: { elevation: 45, intensity: 1, rotation: 0 }, + stringParameters: { + groundColor: '64;64;64', + skyColor: '255;255;255', + top: 'Y-', + }, + booleanParameters: {}, + }, + ]; + + // TODO: factor this? + const isMacLike = + typeof navigator !== 'undefined' && + navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) + ? true + : false; + + const isControlOrCmdPressed = (inputManager: gdjs.InputManager) => { + // On macOS, meta key (Apple/Command key) acts as Control key on Windows/Linux. + return ( + inputManager.isKeyPressed(LEFT_CTRL_KEY) || + inputManager.isKeyPressed(RIGHT_CTRL_KEY) || + inputManager.isKeyPressed(LEFT_META_KEY) || + inputManager.isKeyPressed(RIGHT_META_KEY) + ); + }; + + const isAltPressed = (inputManager: gdjs.InputManager) => { + return ( + inputManager.isKeyPressed(LEFT_ALT_KEY) || + inputManager.isKeyPressed(RIGHT_ALT_KEY) + ); + }; + + const isShiftPressed = (inputManager: gdjs.InputManager) => { + return ( + inputManager.isKeyPressed(LEFT_SHIFT_KEY) || + inputManager.isKeyPressed(RIGHT_SHIFT_KEY) + ); + }; + + const isControlPressedOnly = (inputManager: gdjs.InputManager) => { + return ( + isControlOrCmdPressed(inputManager) && + !isShiftPressed(inputManager) && + !isAltPressed(inputManager) + ); + }; + + const isControlPlusShiftPressedOnly = (inputManager: gdjs.InputManager) => { + return ( + isControlOrCmdPressed(inputManager) && + isShiftPressed(inputManager) && + !isAltPressed(inputManager) + ); + }; + + const isSpacePressed = (inputManager: gdjs.InputManager) => + inputManager.isKeyPressed(SPACE_KEY); + + const shouldDeleteSelection = (inputManager: gdjs.InputManager) => { + return ( + inputManager.isKeyPressed(DEL_KEY) || + inputManager.isKeyPressed(BACKSPACE_KEY) + ); + }; + + const shouldScrollHorizontally = isAltPressed; + + const shouldZoom = (inputManager: gdjs.InputManager) => { + // Browsers trigger a wheel event with ctrlKey or metaKey to true when the user + // does a pinch gesture on a trackpad. If this is the case, we zoom. + // see https://dev.to/danburzo/pinch-me-i-m-zooming-gestures-in-the-dom-a0e + if (isControlOrCmdPressed(inputManager)) return true; + if (isMacLike) { + return isControlOrCmdPressed(inputManager); + } else { + return ( + !isControlOrCmdPressed(inputManager) && + !isAltPressed(inputManager) && + !isShiftPressed(inputManager) + ); + } + }; + + /** Get the identifiers of the touches that are currently active, without the mouse. */ + const getCurrentTouchIdentifiers = (inputManager: gdjs.InputManager) => { + return inputManager + .getAllTouchIdentifiers() + .slice() + .filter((id) => id !== gdjs.InputManager.MOUSE_TOUCH_ID) // Exclude mouse touch + .sort((a, b) => a - b); // Ensure stable order to help comparisons. + }; + + const getTouchesCentroid = (inputManager: gdjs.InputManager) => { + const ids = getCurrentTouchIdentifiers(inputManager); + if (ids.length === 0) return { x: 0, y: 0 }; + let sx = 0; + let sy = 0; + for (let i = 0; i < ids.length; i++) { + sx += inputManager.getTouchX(ids[i]); + sy += inputManager.getTouchY(ids[i]); + } + return { x: sx / ids.length, y: sy / ids.length }; + }; + + const getTouchesDistance = (inputManager: gdjs.InputManager) => { + const ids = getCurrentTouchIdentifiers(inputManager); + if (ids.length === 0) return 0; + return Math.hypot( + inputManager.getTouchX(ids[0]) - inputManager.getTouchX(ids[1]), + inputManager.getTouchY(ids[0]) - inputManager.getTouchY(ids[1]) + ); + }; + + const areSameTouchesSet = (ids1: Array, ids2: Array) => { + if (ids1.length !== ids2.length) return false; + for (let i = 0; i < ids1.length; i++) { + if (ids1[i] !== ids2[i]) return false; + } + return true; + }; + + const freeCameraKeys = [ + LEFT_KEY, + RIGHT_KEY, + UP_KEY, + DOWN_KEY, + W_KEY, + S_KEY, + A_KEY, + D_KEY, + Q_KEY, + E_KEY, + ]; + const shouldSwitchToFreeCamera = (inputManager: gdjs.InputManager) => + !isControlOrCmdPressed(inputManager) && + !isAltPressed(inputManager) && + !isShiftPressed(inputManager) && + freeCameraKeys.some((key) => inputManager.isKeyPressed(key)); + + const snap = (value: float, size: float, offset: float) => + size ? offset + size * Math.round((value - offset) / size) : value; + + class Selection { + private _selectedObjects: Array = []; + + add(object: gdjs.RuntimeObject) { + if (!this._selectedObjects.includes(object)) { + this._selectedObjects.push(object); + } + } + + addAll(objects: RuntimeObject[]) { + for (const object of objects) { + this.add(object); + } + } + + clear() { + this._selectedObjects = []; + } + + toggle(object: gdjs.RuntimeObject) { + const index = this._selectedObjects.indexOf(object); + if (index < 0) { + this._selectedObjects.push(object); + } else { + this._selectedObjects.splice(index, 1); + } + } + + getSelectedObjects() { + return this._selectedObjects; + } + + getLastSelectedObject(options?: { + ignoreIf: (object: gdjs.RuntimeObject) => boolean; + }): gdjs.RuntimeObject | null { + if (options && options.ignoreIf) { + for (let i = this._selectedObjects.length - 1; i >= 0; i--) { + const object = this._selectedObjects[i]; + if (!options.ignoreIf(object)) { + return object; + } + } + return null; + } + + return this._selectedObjects[this._selectedObjects.length - 1] || null; + } + + getAABB(): AABB3D | null { + let aabb: AABB3D | null = null; + for (const object of this._selectedObjects) { + if (is3D(object)) { + const aabb2D = object.getAABB(); + const minZ = object.getUnrotatedAABBMinZ(); + const maxZ = object.getUnrotatedAABBMaxZ(); + if (aabb) { + aabb.min[0] = Math.min(aabb.min[0], aabb2D.min[0]); + aabb.min[1] = Math.min(aabb.min[1], aabb2D.min[1]); + aabb.min[2] = Math.min(aabb.min[2], minZ); + aabb.max[0] = Math.max(aabb.max[0], aabb2D.max[0]); + aabb.max[1] = Math.max(aabb.max[1], aabb2D.max[1]); + aabb.max[2] = Math.max(aabb.max[2], maxZ); + } else { + aabb = { + min: [aabb2D.min[0], aabb2D.min[1], minZ], + max: [aabb2D.max[0], aabb2D.max[1], maxZ], + }; + } + } + } + return aabb; + } + } + + class ObjectMover { + editor: InGameEditor; + _changeHappened = false; + + constructor(editor: InGameEditor) { + this.editor = editor; + } + + _objectInitialPositions: Map< + gdjs.RuntimeObject, + { + x: float; + y: float; + z: float; + rotationX: float; + rotationY: float; + angle: float; + width: float; + height: float; + depth: float; + } + > = new Map(); + + startMove() { + this._changeHappened = false; + this._objectInitialPositions.clear(); + } + + endMove(): boolean { + const changeHappened = this._changeHappened; + this._objectInitialPositions.clear(); + this._changeHappened = false; + return changeHappened; + } + + move( + selectedObjects: Array, + movement: { + translationX: float; + translationY: float; + translationZ: float; + rotationX: float; + rotationY: float; + rotationZ: float; + scaleX: float; + scaleY: float; + scaleZ: float; + } + ) { + selectedObjects.forEach((object) => { + if (this.editor.isInstanceLocked(object)) { + return; + } + + this._changeHappened = + this._changeHappened || + movement.translationX !== 0 || + movement.translationY !== 0 || + movement.translationZ !== 0 || + movement.rotationX !== 0 || + movement.rotationY !== 0 || + movement.rotationZ !== 0 || + movement.scaleX !== 1 || + movement.scaleY !== 1 || + movement.scaleZ !== 1; + + let initialPosition = this._objectInitialPositions.get(object); + if (!initialPosition) { + initialPosition = is3D(object) + ? { + x: object.getX(), + y: object.getY(), + z: object.getZ(), + rotationX: object.getRotationX(), + rotationY: object.getRotationY(), + angle: object.getAngle(), + width: object.getWidth(), + height: object.getHeight(), + depth: object.getDepth(), + } + : { + x: object.getX(), + y: object.getY(), + z: 0, + rotationX: 0, + rotationY: 0, + angle: object.getAngle(), + width: object.getWidth(), + height: object.getHeight(), + depth: 0, + }; + this._objectInitialPositions.set(object, initialPosition); + } + // TODO Remove this when we handle 2D instances controls + if (!is3D(object)) { + return; + } + object.setX(Math.round(initialPosition.x + movement.translationX)); + object.setY(Math.round(initialPosition.y + movement.translationY)); + object.setAngle( + gdjs.evtTools.common.mod( + Math.round(initialPosition.angle + movement.rotationZ), + 360 + ) + ); + if (movement.scaleX !== 1) { + object.setWidth( + Math.round(initialPosition.width * Math.abs(movement.scaleX)) + ); + } + if (movement.scaleY !== 1) { + object.setHeight( + Math.round(initialPosition.height * Math.abs(movement.scaleY)) + ); + } + if (is3D(object)) { + object.setZ(Math.round(initialPosition.z + movement.translationZ)); + object.setRotationX( + gdjs.evtTools.common.mod( + Math.round(initialPosition.rotationX + movement.rotationX), + 360 + ) + ); + object.setRotationY( + gdjs.evtTools.common.mod( + Math.round(initialPosition.rotationY + movement.rotationY), + 360 + ) + ); + if (movement.scaleZ !== 1) { + object.setDepth( + Math.round(initialPosition.depth * Math.abs(movement.scaleZ)) + ); + } + } + }); + } + } + + const getCameraForwardVector = (threeCamera: THREE.Camera) => { + // Make sure camera's matrixWorld is up-to-date (usually is, but good practice). + threeCamera.updateMatrixWorld(); + + // threeCamera.matrixWorld is a 4x4. In Three.js, the columns correspond to: + // [ right.x, up.x, forwardNeg.x, pos.x + // right.y, up.y, forwardNeg.y, pos.y + // right.z, up.z, forwardNeg.z, pos.z + // 0, 0, 0, 1 ] + // + // By default, a Three.js camera looks down the -Z axis, so the "forward" axis + // in the matrix is actually the negative Z column. We'll call it "forward" below. + const elements = threeCamera.matrixWorld.elements; + + // Local forward axis in world space (note we take the negative of that column). + const forward = new THREE.Vector3(-elements[8], elements[9], -elements[10]); + + // Normalize it, just in case (they should generally be unit vectors). + forward.normalize(); + + return { forward }; + }; + + export class InGameEditor { + private _editorId: string = ''; + private _runtimeGame: RuntimeGame; + private _currentScene: gdjs.RuntimeScene | null = null; + private _editedInstanceContainer: gdjs.RuntimeInstanceContainer | null = + null; + private _editedInstanceDataList: InstanceData[] = []; + private _selectedLayerName: string = ''; + private _innerArea: AABB3D | null = null; + private _threeInnerArea: THREE.Object3D | null = null; + private _tempVector2d: THREE.Vector2 = new THREE.Vector2(); + private _raycaster: THREE.Raycaster = new THREE.Raycaster(); + + private _isVisible = true; + private _timeSinceLastInteraction = 0; + private _isFirstFrame = true; + + private _editorCamera; + + /** Keep track of the focus to know if the game was blurred since the last frame. */ + private _windowHadFocus = true; + + // The controls shown to manipulate the selection. + private _selectionControls: { + object: gdjs.RuntimeObject; + dummyThreeObject: THREE.Object3D; + threeTransformControls: THREE_ADDONS.TransformControls; + } | null = null; + private _editorGrid: EditorGrid; + private _selectionControlsMovementTotalDelta: { + translationX: float; + translationY: float; + translationZ: float; + rotationX: float; + rotationY: float; + rotationZ: float; + scaleX: float; + scaleY: float; + scaleZ: float; + } | null = null; + private _hasSelectionActuallyMoved = false; + private _isTransformControlsHovered = false; + private _wasMovingSelectionLastFrame = false; + + private _selectionBox: THREE_ADDONS.SelectionBox | null = null; + private _selectionBoxElement: HTMLDivElement; + private _selectionBoxStartCursorX: float = 0; + private _selectionBoxStartCursorY: float = 0; + + // The selected objects. + private _selection = new Selection(); + private _selectionBoxes: Map = + new Map(); + private _objectMover = new ObjectMover(this); + + private _wasMouseRightButtonPressed = false; + private _wasMouseLeftButtonPressed = false; + private _pressedOriginalCursorX: float = 0; + private _pressedOriginalCursorY: float = 0; + private _previousCursorX: float = 0; + private _previousCursorY: float = 0; + + private _lastClickOnObjectUnderCursor: { + object: gdjs.RuntimeObject | null; + time: number; + } = { + object: null, + time: 0, + }; + + // Dragged new object: + private _draggedNewObject: gdjs.RuntimeObject | null = null; + private _draggedSelectedObject: gdjs.RuntimeObject | null = null; + private _draggedSelectedObjectInitialX: float = 0; + private _draggedSelectedObjectInitialY: float = 0; + private _draggedSelectedObjectInitialZ: float = 0; + private _draggedSelectedObjectTotalDelta: { + translationX: float; + translationY: float; + translationZ: float; + rotationX: float; + rotationY: float; + rotationZ: float; + scaleX: float; + scaleY: float; + scaleZ: float; + } = { + translationX: 0, + translationY: 0, + translationZ: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + scaleX: 1, + scaleY: 1, + scaleZ: 1, + }; + private _instancesEditorSettings: InstancesEditorSettings | null = null; + private _toolbar: Toolbar; + private _inGameEditorSettings: InGameEditorSettings; + + constructor( + game: RuntimeGame, + projectData: ProjectData, + inGameEditorSettings: InGameEditorSettings | null + ) { + this._runtimeGame = game; + this._editorCamera = new EditorCamera(this); + this._editorGrid = new EditorGrid(this); + + this._selectionBoxElement = document.createElement('div'); + this._selectionBoxElement.style.position = 'fixed'; + this._selectionBoxElement.style.backgroundColor = '#f2a63c44'; + this._selectionBoxElement.style.border = '1px solid #f2a63c'; + + this._inGameEditorSettings = + inGameEditorSettings || defaultInGameEditorSettings; + + this._toolbar = new Toolbar({ + getTransformControlsMode: () => this._getTransformControlsMode(), + setTransformControlsMode: (mode: 'translate' | 'rotate' | 'scale') => + this._setTransformControlsMode(mode), + focusOnSelection: () => this._focusOnSelection(), + switchToFreeCamera: () => this._getEditorCamera().switchToFreeCamera(), + switchToOrbitCamera: () => + this._getEditorCamera().switchToOrbitAroundZ0(4000), + isFreeCamera: () => this._getEditorCamera().isFreeCamera(), + getSvgIconUrl: (iconName: string) => getSvgIconUrl(game, iconName), + hasSelectionControlsShown: () => !!this._selectionControls, + }); + + this._applyInGameEditorSettings(); + this.onProjectDataChange(projectData); + } + + private _applyInGameEditorSettings() { + if (typeof document === 'undefined') return; + + const rootElement = document.documentElement; + if (!rootElement) return; + + rootElement.style.setProperty( + '--in-game-editor-theme-icon-button-selected-background-color', + this._inGameEditorSettings.theme.iconButtonSelectedBackgroundColor + ); + rootElement.style.setProperty( + '--in-game-editor-theme-icon-button-selected-color', + this._inGameEditorSettings.theme.iconButtonSelectedColor + ); + rootElement.style.setProperty( + '--in-game-editor-theme-toolbar-background-color', + this._inGameEditorSettings.theme.toolbarBackgroundColor + ); + rootElement.style.setProperty( + '--in-game-editor-theme-toolbar-separator-color', + this._inGameEditorSettings.theme.toolbarSeparatorColor + ); + rootElement.style.setProperty( + '--in-game-editor-theme-text-color-primary', + this._inGameEditorSettings.theme.textColorPrimary + ); + } + + setInGameEditorSettings(inGameEditorSettings: InGameEditorSettings) { + this._inGameEditorSettings = { + ...this._inGameEditorSettings, + ...inGameEditorSettings, + }; + this._applyInGameEditorSettings(); + } + + getRuntimeGame() { + return this._runtimeGame; + } + + onProjectDataChange(projectData: ProjectData): void { + this.setEffectsHiddenInEditor( + !!projectData.properties.areEffectsHiddenInEditor + ); + } + + onLayersDataChange( + layersData: Array, + areEffectsHiddenInEditor: boolean + ): void { + for (const layerData of layersData) { + // Camera controls don't work in orthographic. + if (layerData.cameraType === 'orthographic') { + layerData.cameraType = 'perspective'; + } + layerData.camera3DFieldOfView = editorCameraFov; + // Force 2D and 3D objects to be visible on any layer. + layerData.renderingType = '2d+3d'; + if (areEffectsHiddenInEditor) { + if (layerData.effects !== defaultEffectsData) { + layerData._hiddenEffects = layerData.effects; + layerData.effects = defaultEffectsData; + } + } else { + if (layerData._hiddenEffects) { + layerData.effects = layerData._hiddenEffects; + } + } + } + } + + /** + * Modify the layer data accordingly. + * `gdjs.HotReloader.hotReloadRuntimeSceneLayers` must be run for the + * changes to be applied. + */ + setEffectsHiddenInEditor(areEffectsHiddenInEditor: boolean) { + const projectData = this._runtimeGame.getGameData(); + projectData.properties.areEffectsHiddenInEditor = + areEffectsHiddenInEditor; + for (const layoutData of projectData.layouts) { + this.onLayersDataChange(layoutData.layers, areEffectsHiddenInEditor); + } + } + + areEffectsHidden(): boolean { + return !!this._runtimeGame.getGameData().properties + .areEffectsHiddenInEditor; + } + + getEditorId(): string { + return this._editorId; + } + + getEditedInstanceDataList(): InstanceData[] { + return this._editedInstanceDataList; + } + + getEditedInstanceContainer(): gdjs.RuntimeInstanceContainer | null { + return this._editedInstanceContainer; + } + + getCurrentScene(): gdjs.RuntimeScene | null { + return this._currentScene; + } + + /** + * Return the layer to be used for camera calculus. + * @see getEditorLayer + */ + private getCameraLayer(layerName: string): gdjs.RuntimeLayer | null { + // When the edited container is a custom object, + // only a base layer exists and `getLayer` falls back on it. + return this._currentScene ? this._currentScene.getLayer(layerName) : null; + } + + /** + * Return the layer which contains the objects. + * @see getCameraLayer + */ + private getEditorLayer(layerName: string): gdjs.RuntimeLayer | null { + return this._editedInstanceContainer + ? this._editedInstanceContainer.getLayer(layerName) + : null; + } + + /** + * Called by the RuntimeGame when the game resolution is changed. + * Useful to notify scene and layers that resolution is changed, as they + * might be caching it. + */ + onGameResolutionResized() { + if (!this._currentScene) { + return; + } + this._currentScene.onGameResolutionResized(); + } + + async switchToSceneOrVariant( + editorId: string | null, + sceneName: string | null, + externalLayoutName: string | null, + eventsBasedObjectType: string | null, + eventsBasedObjectVariantName: string | null, + editorCamera3D: EditorCameraState | null + ) { + if (this._currentScene) { + this._currentScene.unloadScene(); + this._currentScene = null; + } + // The 3D scene is rebuilt and the inner area marker is lost in the process. + this._threeInnerArea = null; + this._innerArea = null; + this._selectedLayerName = ''; + // Clear any reference to `RuntimeObject` from the unloaded scene. + this._selectionBoxes.clear(); + this._selectionControls = null; + this._draggedNewObject = null; + this._draggedSelectedObject = null; + const selectedObjectIds = this._selection + .getSelectedObjects() + .map((object) => object.persistentUuid) + .filter(Boolean) as Array; + + let editedInstanceDataList: Array = []; + if (eventsBasedObjectType) { + const eventsBasedObjectVariantData = + this._runtimeGame.getEventsBasedObjectVariantData( + eventsBasedObjectType, + eventsBasedObjectVariantName || '' + ); + if (eventsBasedObjectVariantData) { + editedInstanceDataList = eventsBasedObjectVariantData.instances; + await this._runtimeGame._resourcesLoader.loadResources( + eventsBasedObjectVariantData.usedResources.map( + (resource) => resource.name + ), + () => {} + ); + const sceneAndCustomObject = this._createSceneWithCustomObject( + eventsBasedObjectType, + eventsBasedObjectVariantName || '' + ); + if (sceneAndCustomObject) { + const { scene, customObjectInstanceContainer } = + sceneAndCustomObject; + this._currentScene = scene; + this._editedInstanceContainer = customObjectInstanceContainer; + } + this.setInstancesEditorSettings( + eventsBasedObjectVariantData.editionSettings + ); + this._innerArea = eventsBasedObjectVariantData._initialInnerArea; + } else { + console.warn( + `Couldn't find any variant named "${eventsBasedObjectVariantName || ''}" for ${eventsBasedObjectType}` + ); + } + } else if (sceneName) { + await this._runtimeGame.loadFirstAssetsAndStartBackgroundLoading( + sceneName, + () => {} + ); + // Load the new one + const sceneAndExtensionsData = + this._runtimeGame.getSceneAndExtensionsData(sceneName); + const newScene = new gdjs.RuntimeScene(this._runtimeGame); + newScene.loadFromScene(sceneAndExtensionsData, { + skipCreatingInstances: !!externalLayoutName, + }); + + // Optionally create the objects from an external layout. + if (externalLayoutName) { + const externalLayoutData = + this._runtimeGame.getExternalLayoutData(externalLayoutName); + if (externalLayoutData) { + newScene.createObjectsFrom( + externalLayoutData.instances, + 0, + 0, + 0, + /*trackByPersistentUuid=*/ + true + ); + this.setInstancesEditorSettings(externalLayoutData.editionSettings); + } + } else { + this.setInstancesEditorSettings( + sceneAndExtensionsData!.sceneData.uiSettings + ); + } + this._currentScene = newScene; + this._editedInstanceContainer = newScene; + if (externalLayoutName) { + const externalLayoutData = + this._runtimeGame.getExternalLayoutData(externalLayoutName); + if (externalLayoutData) { + editedInstanceDataList = externalLayoutData.instances; + } + } else { + const sceneAndExtensionsData = + this._runtimeGame.getSceneAndExtensionsData(sceneName); + if (sceneAndExtensionsData) { + editedInstanceDataList = sceneAndExtensionsData.sceneData.instances; + } + } + } else { + console.warn('eventsBasedObjectType or sceneName must be set.'); + } + this._editedInstanceDataList = editedInstanceDataList; + this._editorId = editorId || ''; + if (editorCamera3D) { + this.restoreCameraState(editorCamera3D); + } else { + // TODO Get the visibleScreenArea from the editor. + this.zoomToInitialPosition({ + minX: 0.15, + minY: 0.15, + maxX: 0.85, + maxY: 0.85, + }); + } + + // 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. + this._runtimeGame.getAdditionalOptions().initialRuntimeGameStatus = { + isPaused: this._runtimeGame.isPaused(), + isInGameEdition: this._runtimeGame.isInGameEdition(), + sceneName: sceneName, + injectedExternalLayoutName: externalLayoutName, + skipCreatingInstancesFromScene: !!externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + editorId, + }; + + // Try to keep object selection in case the same scene is reloaded. + this.setSelectedObjects(selectedObjectIds); + this._isFirstFrame = true; + } + + private _createSceneWithCustomObject( + eventsBasedObjectType: string, + eventsBasedObjectVariantName: string + ): { + scene: gdjs.RuntimeScene; + customObjectInstanceContainer: gdjs.CustomRuntimeObjectInstanceContainer; + } | null { + const eventsBasedObjectData = this._runtimeGame.getEventsBasedObjectData( + eventsBasedObjectType + ); + if (!eventsBasedObjectData) { + logger.error( + `A CustomRuntimeObject was open in editor referring to an non existing events based object data with type "${eventsBasedObjectType}".` + ); + return null; + } + + const scene = new gdjs.RuntimeScene(this._runtimeGame); + scene.loadFromScene({ + sceneData: { + variables: [], + instances: [ + { + angle: 0, + customSize: false, + height: 0, + layer: '', + name: 'Object', + persistentUuid: '12345678-1234-1234-1234-123456789abc', + width: 0, + x: 0, + y: 0, + zOrder: 1, + numberProperties: [], + stringProperties: [], + initialVariables: [], + locked: false, + }, + ], + objects: [ + { + name: 'Object', + type: eventsBasedObjectType, + //@ts-ignore + variant: eventsBasedObjectVariantName, + content: {}, + variables: [], + // Add all capabilities just in case events need them. + behaviors: [ + { + name: 'Animation', + type: 'AnimatableCapability::AnimatableBehavior', + }, + { name: 'Effect', type: 'EffectCapability::EffectBehavior' }, + { + name: 'Flippable', + type: 'FlippableCapability::FlippableBehavior', + }, + { + name: 'Object3D', + type: 'Scene3D::Base3DBehavior', + }, + { + name: 'Opacity', + type: 'OpacityCapability::OpacityBehavior', + }, + { + name: 'Resizable', + type: 'ResizableCapability::ResizableBehavior', + }, + { + name: 'Scale', + type: 'ScalableCapability::ScalableBehavior', + }, + { + name: 'Text', + type: 'TextContainerCapability::TextContainerBehavior', + }, + ], + effects: [], + }, + ], + layers: [ + { + ambientLightColorB: 200, + ambientLightColorG: 200, + ambientLightColorR: 200, + camera3DFarPlaneDistance: 10000, + camera3DFieldOfView: 45, + camera3DNearPlaneDistance: 3, + followBaseLayerCamera: false, + isLightingLayer: false, + name: '', + renderingType: '2d+3d', + visibility: true, + cameras: [ + { + defaultSize: true, + defaultViewport: true, + height: 0, + viewportBottom: 1, + viewportLeft: 0, + viewportRight: 1, + viewportTop: 0, + width: 0, + }, + ], + effects: [ + { + effectType: 'Scene3D::HemisphereLight', + name: '3D Light', + doubleParameters: { + elevation: 45, + intensity: 1, + rotation: 0, + }, + stringParameters: { + groundColor: '64;64;64', + skyColor: '255;255;255', + top: 'Y-', + }, + booleanParameters: {}, + }, + ], + }, + ], + r: 32, + v: 32, + b: 32, + mangledName: 'FakeSceneForCustomObject', + name: eventsBasedObjectData.name, + stopSoundsOnStartup: true, + title: '', + behaviorsSharedData: [ + { + name: 'Text', + type: 'TextContainerCapability::TextContainerBehavior', + }, + ], + usedResources: [], + }, + usedExtensionsWithVariablesData: + this._runtimeGame.getGameData().eventsFunctionsExtensions, + }); + const objects = scene.getObjects('Object'); + const object = objects ? objects[0] : null; + if (!object) { + return null; + } + const customObject = object as gdjs.CustomRuntimeObject; + if (!customObject._instanceContainer) { + return null; + } + return { + scene, + customObjectInstanceContainer: customObject._instanceContainer, + }; + } + + updateInnerArea( + areaMinX: float, + areaMinY: float, + areaMinZ: float, + areaMaxX: float, + areaMaxY: float, + areaMaxZ: float + ) { + if (!this._innerArea) { + return; + } + // This only works because `this._innerArea` is the same instance as the + // one used by custom object instances. + this._innerArea.min[0] = areaMinX; + this._innerArea.min[1] = areaMinY; + this._innerArea.min[2] = areaMinZ; + this._innerArea.max[0] = areaMaxX; + this._innerArea.max[1] = areaMaxY; + this._innerArea.max[2] = areaMaxZ; + } + + setSelectedLayerName(layerName: string): void { + this._selectedLayerName = layerName; + } + + setInstancesEditorSettings( + instancesEditorSettings: InstancesEditorSettings + ) { + this._instancesEditorSettings = instancesEditorSettings; + this._editorGrid.setSettings(instancesEditorSettings); + } + + updateInstancesEditorSettings( + instancesEditorSettings: InstancesEditorSettings + ) { + if (this._instancesEditorSettings) { + Object.assign(this._instancesEditorSettings, instancesEditorSettings); + } else { + this._instancesEditorSettings = instancesEditorSettings; + } + this._editorGrid.setSettings(instancesEditorSettings); + } + + private _getTempVector2d(x: float, y: float): THREE.Vector2 { + this._tempVector2d.x = x; + this._tempVector2d.y = y; + return this._tempVector2d; + } + + zoomToInitialPosition(visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) { + if (this._innerArea) { + this.zoomToFitArea( + { + minX: this._innerArea.min[0], + minY: this._innerArea.min[1], + minZ: this._innerArea.min[2], + maxX: this._innerArea.max[0], + maxY: this._innerArea.max[1], + maxZ: this._innerArea.max[2], + }, + visibleScreenArea, + 0.1 + ); + } else { + this.zoomToFitArea( + { + minX: 0, + minY: 0, + minZ: 0, + maxX: this._runtimeGame.getOriginalWidth(), + maxY: this._runtimeGame.getOriginalHeight(), + maxZ: 0, + }, + visibleScreenArea, + 0.1 + ); + } + } + + zoomToFitContent(visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + this.zoomToFitObjects( + editedInstanceContainer.getAdhocListOfAllInstances(), + visibleScreenArea, + 0.01 + ); + } + + zoomToFitSelection(visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) { + this.zoomToFitObjects( + this._selection.getSelectedObjects(), + visibleScreenArea, + 0.2 + ); + } + + zoomToFitObjects( + objects: Array, + visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }, + margin: float + ) { + this._getEditorCamera().zoomToFitObjects( + objects, + visibleScreenArea, + margin + ); + } + + zoomToFitArea( + sceneArea: { + minX: number; + minY: number; + minZ: number; + maxX: number; + maxY: number; + maxZ: number; + }, + visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }, + margin: float + ) { + if (!this._currentScene) return; + this._getEditorCamera().zoomToFitArea( + sceneArea, + visibleScreenArea, + margin + ); + this._getEditorCamera().switchToFreeCamera(); + } + + zoomBy(zoomFactor: float) { + if (!this._currentScene) return; + this._getEditorCamera().zoomBy(zoomFactor); + } + + setZoom(zoom: float) { + if (!this._currentScene) return; + this._getEditorCamera().setZoom(zoom); + } + + getSelectionAABB(): AABB3D | null { + return this._selection.getAABB(); + } + + setSelectedObjects(persistentUuids: Array) { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + const persistentUuidsSet = new Set(persistentUuids); + const selectedObjectsMap = new Map(); + for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) { + if ( + object.persistentUuid && + persistentUuidsSet.has(object.persistentUuid) + ) { + // We can't add the object to the selection directly because they + // would be out of order. + selectedObjectsMap.set(object.persistentUuid, object); + } + } + this._selection.clear(); + for (const instanceUuid of persistentUuids) { + const object = selectedObjectsMap.get(instanceUuid); + if (object) { + this._selection.add(object); + } + } + // Send back default instances sizes. + this._sendSelectionUpdate({ isSendingBackSelectionForDefaultSize: true }); + } + + centerViewOnLastSelectedInstance() { + if (!this._currentScene) return; + + const object = this._selection.getLastSelectedObject(); + if (!object) { + return; + } + + this._getEditorCamera().switchToOrbitAroundObject(object); + } + + private _focusOnSelection() { + // TODO Use the center of the AABB of the whole selection instead + const selectedObject = this._selection.getLastSelectedObject(); + if (!selectedObject) { + return; + } + this._getEditorCamera().switchToOrbitAroundObject(selectedObject); + } + + private _handleCameraMovement() { + const inputManager = this._runtimeGame.getInputManager(); + const currentScene = this._currentScene; + if (!currentScene) return; + + const selectedObject = this._selection.getLastSelectedObject(); + if (inputManager.isKeyPressed(F_KEY) && selectedObject) { + this._focusOnSelection(); + } + + if ( + !this._getEditorCamera().isFreeCamera() && + shouldSwitchToFreeCamera(inputManager) + ) { + this._getEditorCamera().switchToFreeCamera(); + } + + this._getEditorCamera().step(); + + const layerNames = []; + currentScene.getAllLayerNames(layerNames); + layerNames.forEach((layerName) => { + const layer = currentScene.getLayer(layerName); + + this._getEditorCamera().updateCamera(currentScene, layer); + }); + } + + moveSelectionUnderCursor() { + if (!this._currentScene) return; + + const cursor = this._getCursorIn3D(this._selection.getSelectedObjects()); + if (!cursor) { + return; + } + const [cursorX, cursorY, cursorZ] = cursor; + + let minX = Number.MAX_VALUE; + let minY = Number.MAX_VALUE; + let minZ = Number.MAX_VALUE; + let maxX = Number.MIN_VALUE; + let maxY = Number.MIN_VALUE; + for (const object of this._selection.getSelectedObjects()) { + minX = Math.min(minX, object.getAABBLeft()); + minY = Math.min(minY, object.getAABBTop()); + if (is3D(object)) { + minZ = Math.min(minZ, object.getUnrotatedAABBMinZ()); + } + maxX = Math.max(maxX, object.getAABBRight()); + maxY = Math.max(maxY, object.getAABBBottom()); + } + const deltaX = cursorX - (maxX + minX) / 2; + const deltaY = cursorY - (maxY + minY) / 2; + const deltaZ = cursorZ - minZ; + for (const object of this._selection.getSelectedObjects()) { + object.setX(object.getX() + deltaX); + object.setY(object.getY() + deltaY); + if (is3D(object)) { + object.setZ(object.getZ() + deltaZ); + } + } + this._sendSelectionUpdate({ hasSelectedObjectBeenModified: true }); + } + + private _shouldDragSelectedObject(): boolean { + const inputManager = this._runtimeGame.getInputManager(); + return ( + isControlOrCmdPressed(inputManager) && + (!this._selectionControls || + !this._selectionControls.threeTransformControls.dragging) + ); + } + + private _handleSelectedObjectDragging(): void { + const inputManager = this._runtimeGame.getInputManager(); + + // Always check first if we should end an existing drag. + if ( + this._draggedSelectedObject && + (inputManager.isMouseButtonReleased(0) || + !this._shouldDragSelectedObject()) + ) { + this._draggedSelectedObject = null; + const changeHappened = this._objectMover.endMove(); + this._sendSelectionUpdate({ + hasSelectedObjectBeenModified: changeHappened, + }); + } + + // Inspect then if a drag should be started or continued. + if (!this._shouldDragSelectedObject()) { + // We can early return as the rest is not applicable (we've already checked + // if a drag should be ended). + return; + } + if (!this._currentScene) return; + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + if ( + inputManager.isMouseButtonPressed(0) && + !this._draggedSelectedObject + ) { + // Start a new drag. + let object = this.getObjectUnderCursor(); + if (object && this._selection.getSelectedObjects().includes(object)) { + if (isControlOrCmdPressed(inputManager)) { + object = this._duplicateSelectedObjects(object); + if (!object) { + return; + } + } + this._draggedSelectedObject = object; + this._draggedSelectedObjectInitialX = object.getX(); + this._draggedSelectedObjectInitialY = object.getY(); + this._draggedSelectedObjectInitialZ = is3D(object) + ? object.getZ() + : 0; + this._objectMover.startMove(); + } + } + + // Continue an existing drag. + if (!this._draggedSelectedObject) { + return; + } + + let isIntersectionFound = false; + let intersectionX: float = 0; + let intersectionY: float = 0; + let intersectionZ: float = 0; + if (is3D(this._draggedSelectedObject)) { + const cursor = this._getCursorIn3D( + this._selection.getSelectedObjects() + ); + if (cursor) { + isIntersectionFound = true; + [intersectionX, intersectionY, intersectionZ] = cursor; + } + } else { + const projectedCursor = this._getProjectedCursor(); + if (projectedCursor) { + isIntersectionFound = true; + [intersectionX, intersectionY] = projectedCursor; + } + } + if (isIntersectionFound) { + this._editorGrid.setNormal('Z'); + this._editorGrid.setPosition( + intersectionX, + intersectionY, + intersectionZ + ); + const cameraLayer = this.getCameraLayer( + this._draggedSelectedObject.getLayer() + ); + const threeScene = cameraLayer + ? cameraLayer.getRenderer().getThreeScene() + : null; + if (threeScene) { + this._editorGrid.setTreeScene(threeScene); + } + this._editorGrid.setVisible(true); + if (this._editorGrid.isSpanningEnabled(inputManager)) { + intersectionX = this._editorGrid.getSnappedX(intersectionX); + intersectionY = this._editorGrid.getSnappedY(intersectionY); + } + this._draggedSelectedObjectTotalDelta.translationX = + intersectionX - this._draggedSelectedObjectInitialX; + this._draggedSelectedObjectTotalDelta.translationY = + intersectionY - this._draggedSelectedObjectInitialY; + this._draggedSelectedObjectTotalDelta.translationZ = + intersectionZ - this._draggedSelectedObjectInitialZ; + } else { + this._draggedSelectedObjectTotalDelta.translationX = 0; + this._draggedSelectedObjectTotalDelta.translationY = 0; + this._draggedSelectedObjectTotalDelta.translationZ = 0; + } + this._objectMover.move( + this._selection.getSelectedObjects(), + this._draggedSelectedObjectTotalDelta + ); + } + + private _duplicateSelectedObjects( + objectUnderCursor: gdjs.RuntimeObject + ): gdjs.RuntimeObject | null { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return null; + let newObjectUnderCursor: gdjs.RuntimeObject | null = null; + const addedObjects: Array = []; + for (const selectedObject of this._selection.getSelectedObjects()) { + const newObject = editedInstanceContainer.createObject( + selectedObject.getName() + ); + if (!newObject) return null; + newObject.persistentUuid = gdjs.makeUuid(); + newObject.setLayer(selectedObject.getLayer()); + newObject.setX(selectedObject.getX()); + newObject.setY(selectedObject.getY()); + newObject.setAngle(selectedObject.getAngle()); + newObject.setWidth(selectedObject.getWidth()); + newObject.setHeight(selectedObject.getHeight()); + if (is3D(newObject) && is3D(selectedObject)) { + newObject.setZ(selectedObject.getZ()); + newObject.setRotationX(selectedObject.getRotationX()); + newObject.setRotationY(selectedObject.getRotationY()); + newObject.setDepth(selectedObject.getDepth()); + } + addedObjects.push(newObject); + if (selectedObject === objectUnderCursor) { + newObjectUnderCursor = newObject; + } + } + this._selection.clear(); + this._selection.addAll(addedObjects); + this._sendSelectionUpdate({ + addedObjects, + }); + return newObjectUnderCursor; + } + + private _handleSelectionMovement() { + // Finished moving the selection. + if ( + this._wasMovingSelectionLastFrame && + !this._selectionControlsMovementTotalDelta + ) { + const changeHappened = this._objectMover.endMove(); + this._sendSelectionUpdate({ + hasSelectedObjectBeenModified: changeHappened, + }); + } + + // Start moving the selection. + if ( + !this._wasMovingSelectionLastFrame && + this._selectionControlsMovementTotalDelta + ) { + this._objectMover.startMove(); + } + + // Move the selection. + if (this._selectionControlsMovementTotalDelta) { + this._objectMover.move( + this._selection.getSelectedObjects(), + this._selectionControlsMovementTotalDelta + ); + } + } + + private _updateSelectionBox() { + const inputManager = this._runtimeGame.getInputManager(); + const runtimeGame = this._runtimeGame; + const threeRenderer = runtimeGame.getRenderer().getThreeRenderer(); + if (!threeRenderer) return; + const currentScene = this._currentScene; + if (!currentScene) return; + const editedInstanceContainer = this._editedInstanceContainer; + if (!editedInstanceContainer) return; + const cameraLayer = this.getCameraLayer(''); + if (!cameraLayer) return; + + const runtimeLayerRender = cameraLayer.getRenderer(); + const threeCamera = runtimeLayerRender.getThreeCamera(); + const threeScene = runtimeLayerRender.getThreeScene(); + if (!threeCamera || !threeScene) return; + + const cursorX = inputManager.getCursorX(); + const cursorY = inputManager.getCursorY(); + + const touchIds = getCurrentTouchIdentifiers(inputManager); + const hasMultipleTouches = touchIds.length >= 2; + + if ( + inputManager.isMouseButtonPressed(0) && + !this._shouldDragSelectedObject() && + !isSpacePressed(inputManager) && + !hasMultipleTouches + ) { + if (this._wasMouseLeftButtonPressed && this._selectionBox) { + this._selectionBox.endPoint.set( + this._getNormalizedScreenX(cursorX), + this._getNormalizedScreenY(cursorY), + 0.5 + ); + const minX = Math.min(this._selectionBoxStartCursorX, cursorX); + const minY = Math.min(this._selectionBoxStartCursorY, cursorY); + const maxX = Math.max(this._selectionBoxStartCursorX, cursorX); + const maxY = Math.max(this._selectionBoxStartCursorY, cursorY); + this._selectionBoxElement.style.left = minX + 'px'; + this._selectionBoxElement.style.top = minY + 'px'; + this._selectionBoxElement.style.width = maxX - minX + 'px'; + this._selectionBoxElement.style.height = maxY - minY + 'px'; + } else { + this._selectionBox = new THREE_ADDONS.SelectionBox( + threeCamera, + threeScene + ); + this._selectionBox.startPoint.set( + this._getNormalizedScreenX(cursorX), + this._getNormalizedScreenY(cursorY), + 0.5 + ); + const domElementContainer = runtimeGame + .getRenderer() + .getDomElementContainer(); + if (domElementContainer) { + this._selectionBoxElement.style.left = cursorX + 'px'; + this._selectionBoxElement.style.top = cursorY + 'px'; + this._selectionBoxElement.style.width = '0px'; + this._selectionBoxElement.style.height = '0px'; + domElementContainer.appendChild(this._selectionBoxElement); + } + this._selectionBoxStartCursorX = cursorX; + this._selectionBoxStartCursorY = cursorY; + } + } + if ( + (inputManager.isMouseButtonReleased(0) || + this._hasSelectionActuallyMoved) && + this._selectionBox + ) { + if ( + !this._selectionBox.endPoint.equals(this._selectionBox.startPoint) && + !this._hasSelectionActuallyMoved && + !hasMultipleTouches + ) { + // Selection rectangle ended. + + const objects = new Set(); + for (const selectThreeObject of this._selectionBox.select()) { + // TODO Select the object if all its meshes are inside the rectangle + // instead of if any is. + const object = this._getObject3D(selectThreeObject); + if (object) { + objects.add(object); + } + } + if (!isShiftPressed(inputManager)) { + this._selection.clear(); + } + const layer = this.getEditorLayer(this._selectedLayerName); + if (layer && layer.isVisible() && !layer._initialLayerData.isLocked) { + for (const object of objects) { + if (!this.isInstanceSealed(object)) { + this._selection.add(object); + } + } + } + this._sendSelectionUpdate(); + } else { + // Selection rectangle was discarded. + } + this._selectionBox = null; + const domElementContainer = runtimeGame + .getRenderer() + .getDomElementContainer(); + if (domElementContainer) { + domElementContainer.removeChild(this._selectionBoxElement); + } + } + } + + private _handleSelection({ + objectUnderCursor, + }: { + objectUnderCursor: gdjs.RuntimeObject | null; + }) { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + if (this._isTransformControlsHovered) { + return; + } + + const inputManager = this._runtimeGame.getInputManager(); + + if (inputManager.wasKeyJustPressed(ESC_KEY)) { + this._selection.clear(); + this._sendSelectionUpdate(); + } + + // Left click: select the object under the cursor. + if ( + inputManager.isMouseButtonReleased(0) && + this._hasCursorStayedStillWhilePressed({ toleranceRadius: 10 }) + ) { + if (!isShiftPressed(inputManager)) { + this._selection.clear(); + } + let objectToEdit: gdjs.RuntimeObject | null = null; + if (objectUnderCursor) { + const layer = this.getEditorLayer(objectUnderCursor.getLayer()); + if ( + layer && + !layer._initialLayerData.isLocked && + !this.isInstanceSealed(objectUnderCursor) + ) { + this._selection.toggle(objectUnderCursor); + } + + if ( + this._lastClickOnObjectUnderCursor.object === objectUnderCursor && + Date.now() - this._lastClickOnObjectUnderCursor.time < 400 + ) { + // Double click on the same object: edit the object. + objectToEdit = objectUnderCursor; + + this._lastClickOnObjectUnderCursor = { + object: null, + time: 0, + }; + } else { + this._lastClickOnObjectUnderCursor = { + object: objectUnderCursor, + time: Date.now(), + }; + } + } + this._sendSelectionUpdate({ + objectToEdit, + }); + } + + if (shouldDeleteSelection(inputManager)) { + const removedObjects = this._selection.getSelectedObjects(); + removedObjects.forEach((object) => { + object.deleteFromScene(); + }); + this._selection.clear(); + this._sendSelectionUpdate({ + removedObjects, + }); + } + } + + private _updateSelectionOutline({ + objectUnderCursor, + }: { + objectUnderCursor: gdjs.RuntimeObject | null; + }) { + if (!this._currentScene) return; + + const selectedObjects = this._selection.getSelectedObjects(); + + // Add/update boxes for selected objects + selectedObjects.forEach((object) => + this._createBoundingBoxIfNeeded(object) + ); + if ( + objectUnderCursor && + !this._selectionBoxes.has(objectUnderCursor) && + !this.isInstanceSealed(objectUnderCursor) + ) { + this._createBoundingBoxIfNeeded(objectUnderCursor); + } + + // Remove boxes for deselected objects + this._selectionBoxes.forEach((box, object) => { + const isHovered = + object === objectUnderCursor && !this._isTransformControlsHovered; + const isInSelection = selectedObjects.includes(object); + if (!isInSelection && !isHovered) { + box.removeFromParent(); + this._selectionBoxes.delete(object); + } else { + const isLocked = this.isInstanceLocked(object); + + const color = + instanceWireframeColor[ + (isLocked || !is3D(object) ? instanceStateFlag.locked : 0) | + (isInSelection ? instanceStateFlag.selected : 0) | + (isHovered ? instanceStateFlag.hovered : 0) + ] || '#aaaaaa'; + + box.setColor(color); + } + }); + + this._selectionBoxes.forEach((box) => { + box.update(); + }); + } + + private _createBoundingBoxIfNeeded(object: RuntimeObject): void { + if (this._selectionBoxes.has(object)) { + return; + } + const currentScene = this._currentScene; + if (!currentScene) return; + + const objectLayer = this.getEditorLayer(object.getLayer()); + if (!objectLayer) return; + + const threeGroup = objectLayer.getRenderer().getThreeGroup(); + if (!threeGroup) return; + + const objectBoxHelper = new ObjectSelectionBoxHelper(object); + threeGroup.add(objectBoxHelper.container); + this._selectionBoxes.set(object, objectBoxHelper); + } + + private _getTransformControlsMode(): + | 'translate' + | 'rotate' + | 'scale' + | null { + if (!this._selectionControls) { + return null; + } + return this._selectionControls.threeTransformControls.mode; + } + + private _setTransformControlsMode( + mode: 'translate' | 'rotate' | 'scale' + ): void { + if (!this._selectionControls) { + return; + } + const { threeTransformControls, dummyThreeObject } = + this._selectionControls; + threeTransformControls.mode = mode; + + const lastEditableSelectedObject = this._selection.getLastSelectedObject({ + ignoreIf: (object) => + this.isInstanceLocked(object) || this.isInstanceSealed(object), + }); + if (!lastEditableSelectedObject) { + return; + } + const threeObject = lastEditableSelectedObject.get3DRendererObject(); + if (!threeObject) { + return; + } + dummyThreeObject.rotation.copy(threeObject.rotation); + if (threeTransformControls.mode === 'rotate') { + dummyThreeObject.rotation.y = -dummyThreeObject.rotation.y; + dummyThreeObject.rotation.z = -dummyThreeObject.rotation.z; + } + } + + private _forceUpdateSelectionControls() { + let mode: 'translate' | 'rotate' | 'scale' | null = null; + if (this._selectionControls) { + mode = this._selectionControls.threeTransformControls.mode; + this._removeSelectionControls(); + } + this._updateSelectionControls(); + if (mode && this._selectionControls) { + this._setTransformControlsMode(mode); + } + } + + private _updateSelectionControls() { + const inputManager = this._runtimeGame.getInputManager(); + const currentScene = this._currentScene; + if (!currentScene) return; + + const touchIds = getCurrentTouchIdentifiers(inputManager); + const hasMultipleTouches = touchIds.length >= 2; + + // Selection controls are shown on the last object that can be manipulated + // (and if none, selection controls are not shown). + const lastEditableSelectedObject = this._selection.getLastSelectedObject({ + ignoreIf: (object) => + this.isInstanceLocked(object) || this.isInstanceSealed(object), + }); + + // Space or multiple touches will hide the selection controls as they are + // used to move the camera. + const shouldHideSelectionControls = + isSpacePressed(inputManager) || hasMultipleTouches; + + // Remove the selection controls if the last selected object has changed + // or if nothing movable is selected. + if ( + this._selectionControls && + (!lastEditableSelectedObject || + (lastEditableSelectedObject && + this._selectionControls.object !== lastEditableSelectedObject) || + this._shouldDragSelectedObject() || + shouldHideSelectionControls) + ) { + this._removeSelectionControls(); + } + + // Create the selection controls on the last object that can be manipulated. + if ( + lastEditableSelectedObject && + !this._selectionControls && + !this._shouldDragSelectedObject() && + !shouldHideSelectionControls && + lastEditableSelectedObject.get3DRendererObject() + ) { + const cameraLayer = this.getCameraLayer( + lastEditableSelectedObject.getLayer() + ); + if (cameraLayer) { + const runtimeLayerRender = cameraLayer + ? cameraLayer.getRenderer() + : null; + const threeCamera = runtimeLayerRender + ? runtimeLayerRender.getThreeCamera() + : null; + const threeScene = runtimeLayerRender + ? runtimeLayerRender.getThreeScene() + : null; + if (threeCamera && threeScene) { + // Create and attach the transform controls. It is attached to a dummy object + // to avoid the controls to directly move the runtime object (we handle this + // manually). + const threeTransformControls = new THREE_ADDONS.TransformControls( + threeCamera, + this._runtimeGame.getRenderer().getCanvas() || undefined + ); + patchAxesOnTransformControlsGizmos(threeTransformControls); + patchColorsOnTransformControlsGizmos(threeTransformControls); + patchNegativeAxisHandlesOnTransformControlsGizmos( + threeTransformControls + ); + + threeTransformControls.rotation.order = 'ZYX'; + threeTransformControls.scale.y = -1; + threeTransformControls.traverse((obj) => { + // To be detected correctly by OutlinePass. + // @ts-ignore + obj.isTransformControls = true; + }); + + // The dummy object is an invisible object that is the one moved by the transform + // controls. + const dummyThreeObject = new THREE.Object3D(); + this._updateDummyLocation( + dummyThreeObject, + lastEditableSelectedObject, + threeTransformControls + ); + threeScene.add(dummyThreeObject); + + threeTransformControls.attach(dummyThreeObject); + threeScene.add(threeTransformControls); + + // Keep track of the movement so the editor can apply it to the selection. + let initialObjectX = 0; + let initialObjectY = 0; + let initialObjectZ = 0; + const initialDummyPosition = new THREE.Vector3(); + const initialDummyRotation = new THREE.Euler(); + const initialDummyScale = new THREE.Vector3(); + threeTransformControls.addEventListener('change', (e) => { + if (!threeTransformControls.dragging) { + this._selectionControlsMovementTotalDelta = null; + + this._updateDummyLocation( + dummyThreeObject, + lastEditableSelectedObject, + threeTransformControls + ); + // Reset the initial position to the current position, so that + // it's ready to be dragged again. + initialObjectX = lastEditableSelectedObject.getX(); + initialObjectY = lastEditableSelectedObject.getY(); + initialObjectZ = is3D(lastEditableSelectedObject) + ? lastEditableSelectedObject.getZ() + : 0; + initialDummyPosition.copy(dummyThreeObject.position); + initialDummyRotation.copy(dummyThreeObject.rotation); + initialDummyScale.copy(dummyThreeObject.scale); + return; + } + + let translationX = + dummyThreeObject.position.x - initialDummyPosition.x; + let translationY = + dummyThreeObject.position.y - initialDummyPosition.y; + let translationZ = + dummyThreeObject.position.z - initialDummyPosition.z; + if ( + threeTransformControls.mode === 'translate' && + threeTransformControls.axis + ) { + if (threeTransformControls.axis === 'XYZ') { + // We need to override the translation vector because + // `threeTransformControls` don't know that the selection + // must be excluded when looking for the cursor position. + let isIntersectionFound = false; + let intersectionX: float = 0; + let intersectionY: float = 0; + let intersectionZ: float = 0; + if (is3D(lastEditableSelectedObject)) { + const cursor = this._getCursorIn3D( + this._selection.getSelectedObjects() + ); + if (cursor) { + isIntersectionFound = true; + [intersectionX, intersectionY, intersectionZ] = cursor; + } + } else { + const projectedCursor = this._getProjectedCursor(); + if (projectedCursor) { + isIntersectionFound = true; + [intersectionX, intersectionY] = projectedCursor; + } + } + if (isIntersectionFound) { + translationX = intersectionX - initialObjectX; + translationY = intersectionY - initialObjectY; + translationZ = intersectionZ - initialObjectZ; + } else { + translationX = 0; + translationY = 0; + translationZ = 0; + } + } + const isMovingOnX = threeTransformControls.axis.includes('X'); + const isMovingOnY = threeTransformControls.axis.includes('Y'); + const isMovingOnZ = threeTransformControls.axis.includes('Z'); + if (this._editorGrid.isSpanningEnabled(inputManager)) { + if (isMovingOnX) { + translationX = + this._editorGrid.getSnappedX( + initialObjectX + translationX + ) - initialObjectX; + } + if (isMovingOnY) { + translationY = + this._editorGrid.getSnappedY( + initialObjectY + translationY + ) - initialObjectY; + } + if (isMovingOnZ) { + translationZ = + this._editorGrid.getSnappedZ( + initialObjectZ + translationZ + ) - initialObjectZ; + } + } + } + // 0.2 = 20% of the movement speed (Three.js transform controls scaling is too fast) + const scaleDamping = + threeTransformControls.axis && + threeTransformControls.axis.length === 1 + ? 1 + : 0.2; + this._selectionControlsMovementTotalDelta = { + translationX, + translationY, + translationZ, + rotationX: gdjs.toDegrees( + dummyThreeObject.rotation.x - initialDummyRotation.x + ), + rotationY: -gdjs.toDegrees( + dummyThreeObject.rotation.y - initialDummyRotation.y + ), + rotationZ: -gdjs.toDegrees( + dummyThreeObject.rotation.z - initialDummyRotation.z + ), + scaleX: + 1 + + (dummyThreeObject.scale.x / initialDummyScale.x - 1) * + scaleDamping, + scaleY: + 1 + + (dummyThreeObject.scale.y / initialDummyScale.y - 1) * + scaleDamping, + scaleZ: + 1 + + (dummyThreeObject.scale.z / initialDummyScale.z - 1) * + scaleDamping, + }; + + this._hasSelectionActuallyMoved = + this._hasSelectionActuallyMoved || + !dummyThreeObject.position.equals(initialDummyPosition) || + !dummyThreeObject.rotation.equals(initialDummyRotation) || + !dummyThreeObject.scale.equals(initialDummyScale); + }); + + this._selectionControls = { + object: lastEditableSelectedObject, + dummyThreeObject, + threeTransformControls, + }; + } + } + } + + if ( + lastEditableSelectedObject && + this._selectionControls && + !this._draggedNewObject && + !this._draggedSelectedObject + ) { + const { threeTransformControls } = this._selectionControls; + const axis = threeTransformControls.axis; + if (axis) { + const isMovingOnX = axis ? axis.includes('X') : false; + const isMovingOnY = axis ? axis.includes('Y') : false; + const isMovingOnZ = axis ? axis.includes('Z') : false; + let gridNormal: 'X' | 'Y' | 'Z' = 'Z'; + if (isMovingOnZ) { + if (!isMovingOnX && !isMovingOnY) { + // Choose the plan that faces the camera. + const cameraRotation = Math.abs( + gdjs.evtTools.common.angleDifference( + this._editorCamera.getCameraRotation(), + 0 + ) + ); + if (cameraRotation <= 45 || cameraRotation > 135) { + gridNormal = 'Y'; + } else { + gridNormal = 'X'; + } + } else if (!isMovingOnX) { + gridNormal = 'X'; + } else if (!isMovingOnY) { + gridNormal = 'Y'; + } + } + this._editorGrid.setNormal(gridNormal); + } + this._editorGrid.setPosition( + lastEditableSelectedObject.getX(), + lastEditableSelectedObject.getY(), + is3D(lastEditableSelectedObject) + ? lastEditableSelectedObject.getZ() + : 0 + ); + const cameraLayer = this.getCameraLayer( + lastEditableSelectedObject.getLayer() + ); + const threeScene = cameraLayer + ? cameraLayer.getRenderer().getThreeScene() + : null; + if (threeScene) { + this._editorGrid.setTreeScene(threeScene); + } + this._editorGrid.setVisible( + threeTransformControls.mode === 'translate' + ); + } + } + + private _updateDummyLocation( + dummyThreeObject: THREE.Object3D, + lastEditableSelectedObject: gdjs.RuntimeObject, + threeTransformControls: THREE_ADDONS.TransformControls + ) { + const threeObject = lastEditableSelectedObject.get3DRendererObject(); + if (!threeObject) return; + dummyThreeObject.position.copy(threeObject.position); + dummyThreeObject.rotation.copy(threeObject.rotation); + dummyThreeObject.scale.copy(threeObject.scale); + if (threeTransformControls.mode === 'rotate') { + // This is only done for the rotate mode because it messes with the + // orientation of the scale mode. + dummyThreeObject.rotation.y = -dummyThreeObject.rotation.y; + dummyThreeObject.rotation.z = -dummyThreeObject.rotation.z; + + dummyThreeObject.position.set( + lastEditableSelectedObject.getCenterXInScene(), + lastEditableSelectedObject.getCenterYInScene(), + is3D(lastEditableSelectedObject) + ? lastEditableSelectedObject.getCenterZInScene() + : 0 + ); + } else { + dummyThreeObject.position.set( + lastEditableSelectedObject.getX(), + lastEditableSelectedObject.getY(), + is3D(lastEditableSelectedObject) + ? lastEditableSelectedObject.getZ() + : 0 + ); + } + } + + private _removeSelectionControls(): void { + if (!this._selectionControls) { + return; + } + this._selectionControls.threeTransformControls.detach(); + this._selectionControls.threeTransformControls.removeFromParent(); + this._selectionControls.dummyThreeObject.removeFromParent(); + this._editorGrid.setVisible(false); + this._selectionControls = null; + } + + activate(enable: boolean) { + if (enable) { + // Nothing to do. + } else { + this._runtimeGame.getSoundManager().unmuteEverything('in-game-editor'); + this._removeSelectionControls(); + + // Cleanup selection boxes + this._selectionBoxes.forEach((box) => { + box.removeFromParent(); + }); + this._selectionBoxes.clear(); + } + } + + setVisibleStatus(visible: boolean) { + this._isVisible = visible; + } + + private _sendSelectionUpdate(options?: { + hasSelectedObjectBeenModified?: boolean; + isSendingBackSelectionForDefaultSize?: boolean; + addedObjects?: Array; + removedObjects?: Array; + objectToEdit?: gdjs.RuntimeObject | null; + }) { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + + const getPersistentUuidsFromObjects = ( + objects: Array + ): Array => + objects + .map((object) => { + if (!object.persistentUuid) return null; + + return { persistentUuid: object.persistentUuid }; + }) + .filter(isDefined); + + const getSelectedInstances = ( + objects: Array + ): Array => + objects + .map((object) => { + if (!object.persistentUuid) { + return null; + } + return { + persistentUuid: object.persistentUuid, + defaultWidth: object.getOriginalWidth(), + defaultHeight: object.getOriginalHeight(), + defaultDepth: is3D(object) + ? object.getOriginalDepth() + : undefined, + }; + }) + .filter(isDefined); + + const updatedInstances = + options && options.hasSelectedObjectBeenModified + ? this._selection + .getSelectedObjects() + .map((object) => this.getInstanceDataFromRuntimeObject(object)) + .filter(isDefined) + : []; + + const addedInstances = + options && options.addedObjects + ? options.addedObjects + .map((object) => this.getInstanceDataFromRuntimeObject(object)) + .filter(isDefined) + : []; + + const removedInstances = + options && options.removedObjects ? options.removedObjects : []; + + this._removeInstances(removedInstances); + this._updateInstances(updatedInstances); + this._addInstances(addedInstances); + + debuggerClient.sendInstanceChanges({ + isSendingBackSelectionForDefaultSize: options + ? options.isSendingBackSelectionForDefaultSize || false + : false, + updatedInstances, + addedInstances, + selectedInstances: getSelectedInstances( + this._selection.getSelectedObjects() + ), + removedInstances: getPersistentUuidsFromObjects(removedInstances), + objectNameToEdit: options && options.objectToEdit ? options.objectToEdit.getName() : null, + }); + } + + private getInstanceDataFromRuntimeObject( + runtimeObject: gdjs.RuntimeObject + ): InstanceData | null { + if (is3D(runtimeObject)) { + if (!runtimeObject.persistentUuid) return null; + + const width = runtimeObject.getWidth(); + const height = runtimeObject.getHeight(); + const depth = runtimeObject.getDepth(); + const defaultWidth = runtimeObject.getOriginalWidth(); + const defaultHeight = runtimeObject.getOriginalHeight(); + const defaultDepth = runtimeObject.getOriginalDepth(); + + const oldData = this._getInstanceData(runtimeObject.persistentUuid); + const instanceData: InstanceData = { + name: runtimeObject.getName(), + zOrder: runtimeObject.getZOrder(), + persistentUuid: runtimeObject.persistentUuid, + x: runtimeObject.getX(), + y: runtimeObject.getY(), + z: runtimeObject.getZ(), + layer: runtimeObject.getLayer(), + angle: runtimeObject.getAngle(), + rotationY: runtimeObject.getRotationY(), + rotationX: runtimeObject.getRotationX(), + customSize: width !== defaultWidth || height !== defaultHeight, + width, + height, + depth: depth === defaultDepth ? undefined : depth, + locked: oldData ? oldData.locked : false, + sealed: oldData ? oldData.sealed : false, + // TODO: how to transmit/should we transmit other properties? + numberProperties: [], + stringProperties: [], + initialVariables: [], + // @ts-ignore + defaultWidth, + defaultHeight, + defaultDepth, + }; + + return instanceData; + } else { + // TODO: handle 2D objects/instances. + return null; + } + } + + private _removeInstances( + removedInstances: Array<{ persistentUuid: string | null }> + ) { + for (const removedInstance of removedInstances) { + // TODO: Might be worth indexing instances data + const instanceIndex = this._editedInstanceDataList.findIndex( + (instance) => + instance.persistentUuid === removedInstance.persistentUuid + ); + if (instanceIndex >= 0) { + this._editedInstanceDataList.splice(instanceIndex, 1); + } + } + } + + private _updateInstances(updatedInstances: Array) { + for (const updatedInstance of updatedInstances) { + const oldInstance = this._getInstanceData( + updatedInstance.persistentUuid + ); + if (oldInstance) { + gdjs.HotReloader.assignOrDelete(oldInstance, updatedInstance, [ + // These are never modified by the InGameEditor, so don't update them: + 'initialVariables', + 'numberProperties', + 'stringProperties', + ]); + } + } + } + + private _getInstanceData( + persistentUuid: string | null + ): InstanceData | null { + // TODO: Might be worth indexing instances data + return persistentUuid + ? this._editedInstanceDataList.find( + (instanceData) => instanceData.persistentUuid === persistentUuid + ) || null + : null; + } + + isInstanceLocked(object: gdjs.RuntimeObject): boolean { + const instanceData = this._getInstanceData(object.persistentUuid); + return !!instanceData && !!instanceData.locked; + } + + isInstanceSealed(object: gdjs.RuntimeObject): boolean { + const instanceData = this._getInstanceData(object.persistentUuid); + return !!instanceData && !!instanceData.sealed; + } + + private _addInstances(addedInstances: Array) { + for (const addedInstance of addedInstances) { + this._editedInstanceDataList.push(addedInstance); + } + } + + private _updateInnerAreaOutline(): void { + if (!this._currentScene) return; + + const layer = this.getCameraLayer(''); + if (!layer) { + return; + } + const threeGroup = layer.getRenderer().getThreeGroup(); + if (!threeGroup) { + return; + } + if (!this._threeInnerArea) { + const boxMesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial({ + transparent: true, + opacity: 0, + alphaTest: 1, + }) + ); + boxMesh.position.x = 0.5; + boxMesh.position.y = 0.5; + boxMesh.position.z = 0.5; + const box = new THREE.BoxHelper(boxMesh, '#444444'); + box.rotation.order = 'ZYX'; + //box.material.depthTest = false; + box.material.fog = false; + const container = new THREE.Group(); + container.rotation.order = 'ZYX'; + container.add(box); + threeGroup.add(container); + this._threeInnerArea = container; + } + const threeInnerArea = this._threeInnerArea; + if (this._innerArea) { + const innerArea = this._innerArea; + threeInnerArea.scale.x = innerArea.max[0] - innerArea.min[0]; + threeInnerArea.scale.y = innerArea.max[1] - innerArea.min[1]; + threeInnerArea.scale.z = innerArea.max[2] - innerArea.min[2]; + threeInnerArea.position.x = innerArea.min[0]; + threeInnerArea.position.y = innerArea.min[1]; + threeInnerArea.position.z = innerArea.min[2]; + } else { + threeInnerArea.scale.x = this._runtimeGame.getOriginalWidth(); + threeInnerArea.scale.y = this._runtimeGame.getOriginalHeight(); + threeInnerArea.scale.z = 0.01; + threeInnerArea.position.x = 0; + threeInnerArea.position.y = 0; + threeInnerArea.position.z = 0; + } + } + + private _handleContextMenu() { + const inputManager = this._runtimeGame.getInputManager(); + if ( + inputManager.isMouseButtonReleased(1) && + this._hasCursorStayedStillWhilePressed({ toleranceRadius: 0 }) + ) { + this._sendOpenContextMenu( + inputManager.getCursorX(), + inputManager.getCursorY() + ); + } + } + + private _hasCursorStayedStillWhilePressed({ + toleranceRadius, + }: { + toleranceRadius: float; + }) { + const inputManager = this._runtimeGame.getInputManager(); + const deltaX = Math.abs( + this._pressedOriginalCursorX - inputManager.getCursorX() + ); + const deltaY = Math.abs( + this._pressedOriginalCursorY - inputManager.getCursorY() + ); + return ( + deltaX * deltaX + deltaY * deltaY <= toleranceRadius * toleranceRadius + ); + } + + private _sendOpenContextMenu(cursorX: float, cursorY: float) { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + + debuggerClient.sendOpenContextMenu(cursorX, cursorY); + } + + private _handleShortcuts() { + const inputManager = this._runtimeGame.getInputManager(); + let alreadyHandledShortcut = false; + if (isControlPressedOnly(inputManager)) { + // Note: use `wasKeyJustPressed` instead of `wasKeyReleased` to avoid + // macOS stealing the key release ("key up") information + // when the "Meta" key is pressed. + if (inputManager.wasKeyJustPressed(Z_KEY)) { + this._sendUndo(); + alreadyHandledShortcut = true; + } else if (inputManager.wasKeyJustPressed(Y_KEY)) { + this._sendRedo(); + alreadyHandledShortcut = true; + } else if (inputManager.wasKeyJustPressed(C_KEY)) { + this._sendCopy(); + alreadyHandledShortcut = true; + } else if (inputManager.wasKeyJustPressed(V_KEY)) { + this._sendPaste(); + alreadyHandledShortcut = true; + } else if (inputManager.wasKeyJustPressed(X_KEY)) { + this._sendCut(); + alreadyHandledShortcut = true; + } + } + if (isControlPlusShiftPressedOnly(inputManager)) { + if (inputManager.wasKeyJustPressed(Z_KEY)) { + this._sendRedo(); + alreadyHandledShortcut = true; + } + } + + // Send the shortcut to the editor (as the iframe does not bubble up + // the event to the parent window). + if (!alreadyHandledShortcut) { + this._forwardShortcutsToEditor(inputManager); + } + } + + private _sendUndo() { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + debuggerClient.sendUndo(); + } + + private _sendRedo() { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + debuggerClient.sendRedo(); + } + + private _sendCopy() { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + debuggerClient.sendCopy(); + } + + private _sendPaste() { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + debuggerClient.sendPaste(); + } + + private _sendCut() { + const debuggerClient = this._runtimeGame._debuggerClient; + if (!debuggerClient) return; + debuggerClient.sendCut(); + } + + private _forwardShortcutsToEditor(inputManager: gdjs.InputManager) { + const isCtrlPressed = + inputManager.isKeyPressed(LEFT_CTRL_KEY) || + inputManager.isKeyPressed(RIGHT_CTRL_KEY); + const isMetaPressed = + inputManager.isKeyPressed(LEFT_META_KEY) || + inputManager.isKeyPressed(RIGHT_META_KEY); + const isShiftKeyPressed = isShiftPressed(inputManager); + const isAltKeyPressed = isAltPressed(inputManager); + + if ( + !isCtrlPressed && + !isMetaPressed && + !isAltKeyPressed && + !isShiftKeyPressed + ) { + return; + } + + for (const locationAwareKeyCode of this._runtimeGame + .getInputManager() + .exceptionallyGetAllJustPressedKeys()) { + const keyCode = + exceptionallyGetKeyCodeFromLocationAwareKeyCode(locationAwareKeyCode); + + const debuggerClient = this._runtimeGame._debuggerClient; + if (debuggerClient) { + debuggerClient.sendKeyboardShortcut({ + keyCode, + metaKey: isMetaPressed, + ctrlKey: isCtrlPressed, + altKey: isAltKeyPressed, + shiftKey: isShiftKeyPressed, + }); + } + } + } + + cancelDragNewInstance() { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + if (this._draggedNewObject) { + this._draggedNewObject.deleteFromScene(); + this._draggedNewObject = null; + } + this._editorGrid.setVisible(false); + } + + dragNewInstance({ + name, + dropped, + isAltPressed, + }: { + name: string; + dropped: boolean; + isAltPressed: boolean; + }) { + const currentScene = this._currentScene; + if (!currentScene) return; + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + const selectedLayer = this.getEditorLayer(this._selectedLayerName); + if (!selectedLayer) return; + + const inputManager = this._runtimeGame.getInputManager(); + + if (this._draggedNewObject && this._draggedNewObject.getName() !== name) { + this._draggedNewObject.deleteFromScene(); + this._draggedNewObject = null; + } + + if (!this._draggedNewObject) { + const newObject = editedInstanceContainer.createObject(name); + if (!newObject) return; + if (!is3D(newObject)) { + editedInstanceContainer.markObjectForDeletion(newObject); + return; + } + newObject.persistentUuid = gdjs.makeUuid(); + newObject.setLayer(selectedLayer.getName()); + this._draggedNewObject = newObject; + } + + // We don't update the object position when it's dropped because it makes + // the object shift a bit. + // It seems that newIDE doesn't send the last cursor position even when + // the cursor stay still for several frames. The right position is only + // sent it when the object is dropped which make the shift. + // To reproduce the issue: + // - remove the `if` + // - drag the object vertically very fast + // - stay still + // - drop the object + if (!dropped) { + let isCursorFound = false; + let cursorX = 0; + let cursorY = 0; + let cursorZ = 0; + if (is3D(this._draggedNewObject)) { + const cursor = this._getCursorIn3D([this._draggedNewObject]); + if (cursor) { + [cursorX, cursorY, cursorZ] = cursor; + isCursorFound = true; + } + } else { + const projectedCursor = this._getProjectedCursor(); + if (projectedCursor) { + [cursorX, cursorY] = projectedCursor; + isCursorFound = true; + } + } + if (isCursorFound) { + this._editorGrid.setNormal('Z'); + this._editorGrid.setPosition(cursorX, cursorY, cursorZ); + const cameraLayer = this.getCameraLayer( + this._draggedNewObject.getLayer() + ); + const threeScene = cameraLayer + ? cameraLayer.getRenderer().getThreeScene() + : null; + if (threeScene) { + this._editorGrid.setTreeScene(threeScene); + } + this._editorGrid.setVisible(true); + if (this._editorGrid.isSpanningEnabled(inputManager, isAltPressed)) { + cursorX = this._editorGrid.getSnappedX(cursorX); + cursorY = this._editorGrid.getSnappedY(cursorY); + } + // TODO The object Z should be changed according to the new X and Y + // to match the ground. + this._draggedNewObject.setX(Math.round(cursorX)); + this._draggedNewObject.setY(Math.round(cursorY)); + // We don't round on Z because if cubes are stacked and there depth + // is not round it would leave an interstice between them. + if (is3D(this._draggedNewObject)) { + this._draggedNewObject.setZ(cursorZ); + } + } + } + + if (dropped) { + if (this._draggedNewObject) { + const isLayer3D = selectedLayer.getRenderer().getThreeGroup(); + if (isLayer3D) { + const cameraX = selectedLayer.getCameraX(); + const cameraY = selectedLayer.getCameraY(); + const cameraZ = getCameraZ( + currentScene, + selectedLayer.getName(), + 0 + ); + + const closestIntersect = this._getClosestIntersectionUnderCursor([ + this._draggedNewObject, + ]); + if (closestIntersect && !is3D(this._draggedNewObject)) { + // Avoid to create a 2D object hidden under a 3D one. + this.cancelDragNewInstance(); + return; + } + + let cursorX: float; + let cursorY: float; + let cursorZ: float; + if (closestIntersect) { + cursorX = closestIntersect.point.x; + cursorY = -closestIntersect.point.y; + cursorZ = closestIntersect.point.z; + } else { + const projectedCursor = this._getProjectedCursor(); + if (!projectedCursor) { + // Avoid to create an object behind the camera when it's dropped over the horizon. + this.cancelDragNewInstance(); + return; + } + cursorX = projectedCursor[0]; + cursorY = projectedCursor[1]; + cursorZ = 0; + } + + const cursorDistance = Math.hypot( + cursorX - cameraX, + cursorY - cameraY, + cursorZ - cameraZ + ); + if ( + cursorDistance > + selectedLayer.getInitialCamera3DFarPlaneDistance() + ) { + // Avoid to create an object outside of the rendered area. + this.cancelDragNewInstance(); + return; + } + } + this._sendSelectionUpdate({ + addedObjects: [this._draggedNewObject], + }); + } + + this._draggedNewObject = null; + return; + } + } + + /** + * @returns The cursor projected on the plane Z = 0 or `null` if the cursor is in the sky. + */ + _getProjectedCursor(): FloatPoint | null { + const currentScene = this._currentScene; + if (!currentScene) return null; + + const layer = this.getCameraLayer(''); + if (!layer) { + return null; + } + + const cameraX = layer.getCameraX(); + const cameraY = layer.getCameraY(); + const cameraZ = getCameraZ(currentScene, layer.getName(), 0); + + const cursorX = gdjs.evtTools.input.getCursorX( + currentScene, + layer.getName(), + 0 + ); + const cursorY = gdjs.evtTools.input.getCursorY( + currentScene, + layer.getName(), + 0 + ); + + const deltaX = cursorX - cameraX; + const deltaY = cursorY - cameraY; + const deltaZ = 0 - cameraZ; + + const threeCamera = layer.getRenderer().getThreeCamera(); + if (!threeCamera) { + return [cursorX, cursorY]; + } + const { forward } = getCameraForwardVector(threeCamera); + // It happens when the cursor is over the horizon and projected on the plane Z = 0. + const isCursorBehindTheCamera = + forward.dot(new THREE.Vector3(deltaX, deltaY, deltaZ)) < 1; + if (isCursorBehindTheCamera) { + return null; + } + return [cursorX, cursorY]; + } + + reloadInstances(instances: Array) { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + // TODO: Might be worth indexing instances data and runtime objects by their + // persistentUuid (See HotReloader.indexByPersistentUuid). + editedInstanceContainer + .getAdhocListOfAllInstances() + .forEach((runtimeObject) => { + const instance = instances.find( + (instance) => + instance.persistentUuid === runtimeObject.persistentUuid + ); + if (instance) { + runtimeObject.setX(instance.x); + runtimeObject.setY(instance.y); + if (instance.customSize) { + runtimeObject.setWidth(instance.width); + runtimeObject.setHeight(instance.height); + } else { + runtimeObject.setWidth(runtimeObject.getOriginalWidth()); + runtimeObject.setHeight(runtimeObject.getOriginalHeight()); + } + runtimeObject.setAngle(instance.angle); + runtimeObject.setLayer(instance.layer); + if (is3D(runtimeObject)) { + runtimeObject.setZ(instance.z === undefined ? 0 : instance.z); + runtimeObject.setRotationX( + instance.rotationX == undefined ? 0 : instance.rotationX + ); + runtimeObject.setRotationY( + instance.rotationY == undefined ? 0 : instance.rotationY + ); + runtimeObject.setDepth( + instance.depth == undefined + ? runtimeObject.getOriginalDepth() + : instance.depth + ); + } + runtimeObject.extraInitializationFromInitialInstance(instance); + } + }); + this._updateInstances(instances); + this._forceUpdateSelectionControls(); + } + + addInstances(instances: Array) { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + editedInstanceContainer.createObjectsFrom(instances, 0, 0, 0, true); + this._addInstances(instances); + } + + deleteSelection() { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return; + + this._removeInstances(this._selection.getSelectedObjects()); + for (const object of this._selection.getSelectedObjects()) { + object.deleteFromScene(); + } + this._selection.clear(); + } + + private _getClosestIntersectionUnderCursor( + excludedObjects?: Array + ): THREE.Intersection | null { + const runtimeGame = this._runtimeGame; + const firstIntersectsByLayer: { + [layerName: string]: null | { + intersect: THREE.Intersection; + }; + } = {}; + const cursorX = runtimeGame.getInputManager().getCursorX(); + const cursorY = runtimeGame.getInputManager().getCursorY(); + + const layerNames = []; + const currentScene = this._currentScene; + const threeRenderer = runtimeGame.getRenderer().getThreeRenderer(); + if (!currentScene || !threeRenderer) return null; + + // Only check layer 0, on which Three.js objects are by default, + // and move selection boxes + dragged object to layer 1 so they + // are not considered by raycasting. + this._raycaster.layers.set(0); + this._selectionBoxes.forEach((box) => box.setLayer(1)); + if (this._threeInnerArea) { + for (const child of this._threeInnerArea.children) { + child.layers.set(1); + } + } + if (excludedObjects) { + for (const excludedObject of excludedObjects) { + if (is3D(excludedObject)) { + const draggedRendererObject = excludedObject.get3DRendererObject(); + if (draggedRendererObject) { + draggedRendererObject.layers.set(1); + draggedRendererObject.traverse((object) => object.layers.set(1)); + } + } + } + } + + currentScene.getAllLayerNames(layerNames); + layerNames.forEach((layerName) => { + const runtimeLayerRender = currentScene + .getLayer(layerName) + .getRenderer(); + const threeCamera = runtimeLayerRender.getThreeCamera(); + const threeGroup = runtimeLayerRender.getThreeGroup(); + if (!threeCamera || !threeGroup) return; + + // 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 + const normalizedDeviceCoordinates = this._getTempVector2d( + this._getNormalizedScreenX(cursorX), + this._getNormalizedScreenY(cursorY) + ); + this._raycaster.setFromCamera(normalizedDeviceCoordinates, threeCamera); + const intersects = this._raycaster.intersectObjects( + threeGroup.children, + true + ); + + const firstIntersect = intersects[0]; + if (!firstIntersect) return; + + firstIntersectsByLayer[layerName] = { + intersect: firstIntersect, + }; + }); + + // Reset selection boxes layers so they are properly displayed. + this._selectionBoxes.forEach((box) => box.setLayer(0)); + if (this._threeInnerArea) { + for (const child of this._threeInnerArea.children) { + child.layers.set(0); + } + } + // Also reset the layer of the object being added. + if (excludedObjects) { + for (const excludedObject of excludedObjects) { + if (is3D(excludedObject)) { + const draggedRendererObject = excludedObject.get3DRendererObject(); + if (draggedRendererObject) { + draggedRendererObject.layers.set(0); + draggedRendererObject.traverse((object) => object.layers.set(0)); + } + } + } + } + + let closestIntersect: THREE.Intersection | null = null; + for (const intersect of Object.values(firstIntersectsByLayer)) { + if ( + intersect && + (!closestIntersect || + intersect.intersect.distance < closestIntersect.distance) + ) { + closestIntersect = intersect.intersect; + } + } + + return closestIntersect; + } + + private _getCursorIn3D( + excludedObjects?: Array + ): Point3D | null { + const closestIntersect = + this._getClosestIntersectionUnderCursor(excludedObjects); + if (closestIntersect) { + return [ + closestIntersect.point.x, + -closestIntersect.point.y, + closestIntersect.point.z, + ]; + } + const projectedCursor = this._getProjectedCursor(); + if (!projectedCursor) { + return null; + } + return [projectedCursor[0], projectedCursor[1], 0]; + } + + private _getNormalizedScreenX(x: float): float { + return (x / this._runtimeGame.getGameResolutionWidth()) * 2 - 1; + } + + private _getNormalizedScreenY(y: float): float { + return -(y / this._runtimeGame.getGameResolutionHeight()) * 2 + 1; + } + + getObjectUnderCursor(): gdjs.RuntimeObject | null { + const closestIntersect = this._getClosestIntersectionUnderCursor(); + if (!closestIntersect) { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) { + return null; + } + const cursor = this._getCursorIn3D(); + if (!cursor || cursor[2] !== 0) { + return null; + } + let topObject2D: gdjs.RuntimeObject | null = null; + let topLayer: gdjs.RuntimeLayer | null = null; + let topLayerIndex = 0; + for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) { + if (is3D(object) || !object.cursorOnObject()) { + continue; + } + const layer = editedInstanceContainer.getLayer(object.getLayer()); + const layerIndex = + editedInstanceContainer._orderedLayers.indexOf(layer); + if ( + !topObject2D || + layerIndex > topLayerIndex || + (layer === topLayer && object.getZOrder() > topObject2D.getZOrder()) + ) { + topObject2D = object; + topLayer = layer; + topLayerIndex = layerIndex; + } + } + return topObject2D; + } + return this._getObject3D(closestIntersect.object); + } + + private _getObject3D( + initialThreeObject: THREE.Object3D + ): gdjs.RuntimeObject | null { + const editedInstanceContainer = this.getEditedInstanceContainer(); + if (!editedInstanceContainer) return null; + + // Walk back up the object hierarchy to find the runtime object. + // We sadly need to do that because the intersection can be found on a Mesh or other + // child Three.js object, instead of the one exposed by the gdjs.RuntimeObject. + let threeObject: THREE.Object3D | null = initialThreeObject; + while (threeObject) { + const runtimeObject: gdjs.RuntimeObject | null = + // @ts-ignore + threeObject.gdjsRuntimeObject; + if (runtimeObject) { + let rootRuntimeObject = runtimeObject; + while ( + rootRuntimeObject.getInstanceContainer() instanceof + gdjs.CustomRuntimeObjectInstanceContainer && + rootRuntimeObject.getInstanceContainer() !== editedInstanceContainer + ) { + rootRuntimeObject = ( + rootRuntimeObject.getInstanceContainer() as gdjs.CustomRuntimeObjectInstanceContainer + ).getOwner(); + } + return rootRuntimeObject; + } + threeObject = threeObject.parent || null; + } + return null; + } + + getCameraState(): EditorCameraState { + return this._getEditorCamera().getCameraState(); + } + + restoreCameraState(editorCamera3D: EditorCameraState) { + this._getEditorCamera().restoreCameraState(editorCamera3D); + } + + private _updateMouseCursor() { + const mouseCursor = this._getEditorCamera().getRequestedMouseCursor(); + + const canvas = this._runtimeGame.getRenderer().getCanvas(); + if (canvas) { + canvas.style.cursor = mouseCursor || 'default'; + } + } + + private _handleTransformControlsMode() { + const inputManager = this._runtimeGame.getInputManager(); + if (inputManager.wasKeyJustPressed(KEY_DIGIT_1)) { + this._setTransformControlsMode('translate'); + } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_2)) { + this._setTransformControlsMode('rotate'); + } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_3)) { + this._setTransformControlsMode('scale'); + } + } + + updateTargetFramerate(elapsedTime: float) { + const inputManager = this._runtimeGame.getInputManager(); + if ( + inputManager.anyKeyPressed() || + inputManager.anyMouseButtonPressed() || + inputManager.getAllTouchIdentifiers().length > 0 || + inputManager.getMouseWheelDelta() !== 0 || + inputManager.getMouseWheelDeltaX() !== 0 || + inputManager.getMouseWheelDeltaZ() !== 0 + ) { + this._timeSinceLastInteraction = 0; + } + if (this._draggedNewObject) { + this._timeSinceLastInteraction = 0; + } + this._timeSinceLastInteraction += elapsedTime; + + // Adapt the framerate to avoid consuming too much CPU when the editor is not visible + // or not interacted with. + if (!this._isVisible) { + this._runtimeGame.setMaximumFps(0.3); + } else { + if (this._timeSinceLastInteraction > 1000) { + this._runtimeGame.setMaximumFps(10); + } else { + this._runtimeGame.setMaximumFps(60); + } + } + } + + updateAndRender() { + const objectUnderCursor: gdjs.RuntimeObject | null = + this.getObjectUnderCursor(); + + this._runtimeGame.getSoundManager().muteEverything('in-game-editor'); + + const inputManager = this._runtimeGame.getInputManager(); + + // Ensure we don't keep keys considered as pressed if the editor is blurred. + if (!hasWindowFocus && this._windowHadFocus) { + inputManager.releaseAllPressedKeys(); + } + this._windowHadFocus = hasWindowFocus; + + if ( + !this._wasMouseLeftButtonPressed && + !this._wasMouseRightButtonPressed && + (inputManager.isMouseButtonPressed(0) || + inputManager.isMouseButtonPressed(1)) + ) { + this._pressedOriginalCursorX = inputManager.getCursorX(); + this._pressedOriginalCursorY = inputManager.getCursorY(); + } + + if (!this._selectionControls) { + this._isTransformControlsHovered = false; + } else if ( + this._previousCursorX !== inputManager.getMouseX() || + this._previousCursorY !== inputManager.getMouseY() + ) { + this._isTransformControlsHovered = + !!this._selectionControls.threeTransformControls.axis; + } + + this._handleTransformControlsMode(); + this._handleCameraMovement(); + this._handleSelectedObjectDragging(); + this._handleSelectionMovement(); + this._updateSelectionBox(); + this._handleSelection({ objectUnderCursor }); + this._updateSelectionOutline({ objectUnderCursor }); + // Custom objects only update their position at the end of the frame + // because they don't override position setters like built-in objects do. + // Since the instance position is not yet set when `onCreated` is called, + // they will be at (0; 0; 0) during the 1st step. + // When they are selected and `switchToSceneOrVariant` has just been + // called, it avoid to put the control at (0; 0; 0). + if (!this._isFirstFrame) { + this._updateSelectionControls(); + } + this._updateInnerAreaOutline(); + this._handleContextMenu(); + this._handleShortcuts(); + this._updateMouseCursor(); + + const domElementContainer = this._runtimeGame + .getRenderer() + .getDomElementContainer(); + if (domElementContainer) { + this._toolbar.render(domElementContainer); + } + + this._wasMovingSelectionLastFrame = + !!this._selectionControlsMovementTotalDelta; + if (!this._selectionControlsMovementTotalDelta) { + this._hasSelectionActuallyMoved = false; + } + this._wasMouseLeftButtonPressed = inputManager.isMouseButtonPressed(0); + this._wasMouseRightButtonPressed = inputManager.isMouseButtonPressed(1); + this._previousCursorX = inputManager.getMouseX(); + this._previousCursorY = inputManager.getMouseY(); + + if (this._currentScene) { + this._currentScene._updateObjectsForInGameEditor(); + this._currentScene.render(); + } + this._isFirstFrame = false; + } + + private _getEditorCamera(): EditorCamera { + return this._editorCamera; + } + } + + class Toolbar { + private _renderedElements: { + container: HTMLDivElement; + moveButton: HTMLButtonElement; + rotateButton: HTMLButtonElement; + scaleButton: HTMLButtonElement; + freeCameraButton: HTMLButtonElement; + orbitCameraButton: HTMLButtonElement; + } | null = null; + private _parent: HTMLElement | null = null; + private _getTransformControlsMode: () => + | 'translate' + | 'rotate' + | 'scale' + | null; + private _setTransformControlsMode: ( + mode: 'translate' | 'rotate' | 'scale' + ) => void; + private _focusOnSelection: () => void; + private _switchToFreeCamera: () => void; + private _switchToOrbitCamera: () => void; + private _isFreeCamera: () => boolean; + private _getSvgIconUrl: (iconName: string) => string; + private _hasSelectionControlsShown: () => boolean; + + private addOrUpdateToolbarStyle() { + const id = 'InGameEditor-Toolbar-Style'; + + let styleElement = document.getElementById(id); + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.id = 'InGameEditor-Toolbar-Style'; + document.head.appendChild(styleElement); + } + + styleElement.textContent = ` + .InGameEditor-Toolbar-Centering-Container { + position: fixed; + left: 0; + right: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + transition: transform 0.1s ease-in-out; + } + .InGameEditor-Toolbar-Container { + position: relative; + pointer-events: all; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + top: 2px; + border-radius: 3px; + padding: 4px; + gap: 6px; + } + .InGameEditor-Toolbar-Container-Background { + position: absolute; + inset: 0; + background-color: var(--in-game-editor-theme-toolbar-background-color); + opacity: 0.5; + border-radius: 3px; + z-index: -1; + } + .InGameEditor-Toolbar-Button { + width: 24px; + height: 24px; + border-radius: 4px; + border: none; + padding: 0; + border-width: 0; + display: flex; + align-items: center; + justify-content: center; + + background-color: transparent; + } + .InGameEditor-Toolbar-Button-Icon { + width: 20px; + height: 20px; + display:inline-block; + background-color: var(--in-game-editor-theme-text-color-primary); + } + .InGameEditor-Toolbar-Button:hover:not(.InGameEditor-Toolbar-Button-Active):not(.InGameEditor-Toolbar-Button-Disabled) { + background-color: rgba(255, 255, 255, 0.08); + } + .InGameEditor-Toolbar-Button-Active { + background-color: var(--in-game-editor-theme-icon-button-selected-background-color); + } + .InGameEditor-Toolbar-Button-Active .InGameEditor-Toolbar-Button-Icon { + background-color: var(--in-game-editor-theme-icon-button-selected-color); + } + .InGameEditor-Toolbar-Divider { + width: 1px; + height: 24px; + background-color: var(--in-game-editor-theme-toolbar-separator-color); + } + `; + } + + constructor({ + getTransformControlsMode, + setTransformControlsMode, + focusOnSelection, + switchToFreeCamera, + switchToOrbitCamera, + isFreeCamera, + getSvgIconUrl, + hasSelectionControlsShown, + }: { + getTransformControlsMode: () => 'translate' | 'rotate' | 'scale' | null; + setTransformControlsMode: ( + mode: 'translate' | 'rotate' | 'scale' + ) => void; + focusOnSelection: () => void; + switchToFreeCamera: () => void; + switchToOrbitCamera: () => void; + isFreeCamera: () => boolean; + getSvgIconUrl: (iconName: string) => string; + hasSelectionControlsShown: () => boolean; + }) { + this._getTransformControlsMode = getTransformControlsMode; + this._setTransformControlsMode = setTransformControlsMode; + this._focusOnSelection = focusOnSelection; + this._switchToFreeCamera = switchToFreeCamera; + this._switchToOrbitCamera = switchToOrbitCamera; + this._isFreeCamera = isFreeCamera; + this._getSvgIconUrl = getSvgIconUrl; + this._hasSelectionControlsShown = hasSelectionControlsShown; + + this.addOrUpdateToolbarStyle(); + } + + render(parent: HTMLElement) { + if (this._renderedElements && this._parent !== parent) { + this._renderedElements.container.remove(); + this._renderedElements = null; + } + this._parent = parent; + + const makeIcon = ({ svgIconUrl }: { svgIconUrl: string }) => ( + + ); + + if (!this._renderedElements) { + const container = ( +
+
+
+ + +
+ + + +
+ +
+
+ ); + this._parent.appendChild(container); + + this._renderedElements = { + container, + moveButton: container.querySelector('#move-button')!, + rotateButton: container.querySelector('#rotate-button')!, + scaleButton: container.querySelector('#scale-button')!, + freeCameraButton: container.querySelector('#free-camera-button')!, + orbitCameraButton: container.querySelector('#orbit-camera-button')!, + }; + } + + const displayed = this._hasSelectionControlsShown(); + + this._renderedElements.container.tabIndex = displayed ? 0 : -1; + this._renderedElements.container.style.transform = displayed + ? 'translateY(0)' + : 'translateY(-50px)'; + + const transformControlsMode = this._getTransformControlsMode(); + this._renderedElements.freeCameraButton.classList.toggle( + 'InGameEditor-Toolbar-Button-Active', + this._isFreeCamera() + ); + this._renderedElements.orbitCameraButton.classList.toggle( + 'InGameEditor-Toolbar-Button-Active', + !this._isFreeCamera() + ); + this._renderedElements.moveButton.classList.toggle( + 'InGameEditor-Toolbar-Button-Active', + transformControlsMode === 'translate' + ); + this._renderedElements.rotateButton.classList.toggle( + 'InGameEditor-Toolbar-Button-Active', + transformControlsMode === 'rotate' + ); + this._renderedElements.scaleButton.classList.toggle( + 'InGameEditor-Toolbar-Button-Active', + transformControlsMode === 'scale' + ); + } + } + + class EditorGrid { + editor: gdjs.InGameEditor; + gridHelper: THREE.GridHelper; + isVisible = true; + normal: 'Z' | 'Y' | 'X' = 'Z'; + position = new THREE.Vector3(); + + isForcefullyHidden = true; + gridWidth: float = 0; + gridHeight: float = 0; + gridDepth: float = 0; + gridOffsetX: float = 0; + gridOffsetY: float = 0; + gridOffsetZ: float = 0; + gridColor: integer = 0; + gridAlpha: float = 1; + isSnappingEnabledByDefault = false; + threeScene: THREE.Scene | null = null; + + constructor(editor: gdjs.InGameEditor) { + this.editor = editor; + this.gridHelper = new THREE.GridHelper(); + } + + setSettings(instancesEditorSettings: InstancesEditorSettings): void { + this.isForcefullyHidden = !instancesEditorSettings.grid; + this.gridWidth = instancesEditorSettings.gridWidth || 0; + this.gridHeight = instancesEditorSettings.gridHeight || 0; + this.gridDepth = + instancesEditorSettings.gridDepth === undefined + ? 32 + : instancesEditorSettings.gridDepth; + this.gridOffsetX = instancesEditorSettings.gridOffsetX || 0; + this.gridOffsetY = instancesEditorSettings.gridOffsetY || 0; + this.gridOffsetZ = instancesEditorSettings.gridOffsetZ || 0; + this.gridColor = instancesEditorSettings.gridColor; + this.gridAlpha = instancesEditorSettings.gridAlpha; + this.isSnappingEnabledByDefault = instancesEditorSettings.snap; + this.rebuildGrid(); + } + + private rebuildGrid(): void { + this.gridHelper.removeFromParent(); + this.gridHelper.dispose(); + this.gridHelper = new THREE.GridHelper( + 10, + 10, + this.gridColor, + this.gridColor + ); + this.gridHelper.material.transparent = true; + this.gridHelper.material.opacity = this.gridAlpha; + this.gridHelper.rotation.order = 'ZYX'; + this.updateVisibility(); + this.updateLocation(); + if (this.threeScene) { + this.threeScene.add(this.gridHelper); + } + } + + private updateLocation() { + const { gridWidth, gridHeight, gridDepth } = this; + const { x, y, z } = this.position; + if (this.normal === 'X') { + this.gridHelper.rotation.set(0, 0, Math.PI / 2); + this.gridHelper.scale.set(gridWidth, 1, gridDepth || 0); + this.gridHelper.position.set( + x, + this.getSnappedY(y), + this.getSnappedZ(z) + ); + } else if (this.normal === 'Y') { + this.gridHelper.rotation.set(0, 0, 0); + this.gridHelper.scale.set(gridHeight, 1, gridDepth || 0); + this.gridHelper.position.set( + this.getSnappedX(x), + y, + this.getSnappedZ(z) + ); + } else { + this.gridHelper.rotation.set(Math.PI / 2, 0, 0); + this.gridHelper.scale.set(gridWidth, 1, gridHeight); + this.gridHelper.position.set( + this.getSnappedX(x), + this.getSnappedY(y), + z + ); + } + } + + setTreeScene(threeScene: THREE.Scene): void { + this.threeScene = threeScene; + this.gridHelper.removeFromParent(); + threeScene.add(this.gridHelper); + } + + setVisible(isVisible: boolean): void { + this.isVisible = isVisible; + this.updateVisibility(); + } + + private updateVisibility(): void { + this.gridHelper.visible = this.isVisible && !this.isForcefullyHidden; + } + + setNormal(normal: 'X' | 'Y' | 'Z') { + this.normal = normal; + if (this.normal === 'X') { + this.gridHelper.rotation.set(0, 0, Math.PI / 2); + } else if (this.normal === 'Y') { + this.gridHelper.rotation.set(0, 0, 0); + } else { + this.gridHelper.rotation.set(Math.PI / 2, 0, 0); + } + this.updateLocation(); + } + + setPosition(x: float, y: float, z: float): void { + this.position.set(x, y, z); + this.updateLocation(); + } + + getSnappedX(x: float): float { + const { gridWidth, gridOffsetX } = this; + return snap(x, gridWidth, gridOffsetX); + } + + getSnappedY(y: float): float { + const { gridHeight, gridOffsetY } = this; + return snap(y, gridHeight, gridOffsetY); + } + + getSnappedZ(z: float): float { + const { gridDepth, gridOffsetY } = this; + return snap(z, gridDepth || 0, gridOffsetY); + } + + isSpanningEnabled( + inputManager: gdjs.InputManager, + considerAltPressed?: boolean + ): boolean { + const altPressed = + considerAltPressed === undefined + ? isAltPressed(inputManager) + : considerAltPressed; + return this.isSnappingEnabledByDefault !== altPressed; + } + } + + export type EditorCameraState = { + cameraMode: 'free' | 'orbit'; + positionX: float; + positionY: float; + positionZ: float; + rotationAngle: float; + elevationAngle: float; + distance: float; + }; + + class EditorCamera implements CameraControl { + editor: gdjs.InGameEditor; + orbitCameraControl: OrbitCameraControl; + freeCameraControl: FreeCameraControl; + private _hasChanged = false; + private _hadChanged = false; + private _mouseCursor: string | null = null; + + constructor(editor: gdjs.InGameEditor) { + this.editor = editor; + this.orbitCameraControl = new OrbitCameraControl(this); + this.freeCameraControl = new FreeCameraControl(this); + this.orbitCameraControl.setEnabled(false); + } + + isFreeCamera(): boolean { + return this.freeCameraControl.isEnabled(); + } + + private getActiveCamera() { + return this.isFreeCamera() + ? this.freeCameraControl + : this.orbitCameraControl; + } + + getRequestedMouseCursor(): string | null { + return this._mouseCursor; + } + + switchToOrbitAroundObject(object: gdjs.RuntimeObject): void { + this.switchToOrbitAroundPosition( + object.getCenterXInScene(), + object.getCenterYInScene(), + is3D(object) ? object.getUnrotatedAABBMinZ() : 0 + ); + this.onHasCameraChanged(); + } + + switchToOrbitAroundPosition( + targetX: float, + targetY: float, + targetZ: float + ): void { + this.orbitCameraControl.target.x = targetX; + this.orbitCameraControl.target.y = targetY; + this.orbitCameraControl.target.z = targetZ; + if (this.freeCameraControl.isEnabled()) { + this.orbitCameraControl.rotationAngle = + this.freeCameraControl.rotationAngle; + this.orbitCameraControl.elevationAngle = + this.freeCameraControl.elevationAngle; + } + this.orbitCameraControl.setEnabled(true); + this.freeCameraControl.setEnabled(false); + this.onHasCameraChanged(); + } + + switchToOrbitAroundZ0(maxDistance: number): void { + if (this.freeCameraControl.isEnabled()) { + // Match orientation and orbit from the current free camera position. + this.orbitCameraControl.rotationAngle = + this.freeCameraControl.rotationAngle; + this.orbitCameraControl.elevationAngle = + this.freeCameraControl.elevationAngle; + this.orbitCameraControl.orbitFromPositionAroundZ0( + this.freeCameraControl.position.x, + this.freeCameraControl.position.y, + this.freeCameraControl.position.z, + maxDistance + ); + } + + this.orbitCameraControl.setEnabled(true); + this.freeCameraControl.setEnabled(false); + this.onHasCameraChanged(); + } + + switchToFreeCamera(): void { + this.orbitCameraControl.setEnabled(false); + this.freeCameraControl.setEnabled(true); + + this.freeCameraControl.position.x = this.orbitCameraControl.getCameraX(); + this.freeCameraControl.position.y = this.orbitCameraControl.getCameraY(); + this.freeCameraControl.position.z = this.orbitCameraControl.getCameraZ(); + this.freeCameraControl.rotationAngle = + this.orbitCameraControl.rotationAngle; + this.freeCameraControl.elevationAngle = + this.orbitCameraControl.elevationAngle; + this.onHasCameraChanged(); + } + + resetRotationToTopDown(): void { + this.orbitCameraControl.resetRotationToTopDown(); + this.freeCameraControl.resetRotationToTopDown(); + this.onHasCameraChanged(); + } + + setOrbitDistance(distance: number): void { + this.orbitCameraControl.distance = distance; + this.onHasCameraChanged(); + } + + step(): void { + const runtimeGame = this.editor.getRuntimeGame(); + const inputManager = runtimeGame.getInputManager(); + + const touchIds = getCurrentTouchIdentifiers(inputManager); + const touchCount = touchIds.length; + + // Always allow to use Space+click to switch to free camera and pan. + // Display a grab cursor to indicate that. + this._mouseCursor = null; + if (isSpacePressed(inputManager)) { + this._mouseCursor = 'grab'; + + // Switch to pan if space + left click is used. + if (inputManager.isMouseButtonPressed(0) && !this.isFreeCamera()) { + this.switchToFreeCamera(); + } + } + // Also switch to pan if shift + wheel click is used. + if ( + isShiftPressed(inputManager) && + inputManager.isMouseButtonPressed(2) && + !this.isFreeCamera() + ) { + this.switchToFreeCamera(); + } + // Shift to orbit if just mouse wheel click is used + if ( + !isShiftPressed(inputManager) && + inputManager.isMouseButtonPressed(2) && + this.isFreeCamera() + ) { + const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level. + this.switchToOrbitAroundZ0(maxDistance); + } + // With touches, 2 touches will always pan/zoom the camera with the free camera. + if (touchCount === 2 && !this.isFreeCamera()) { + this.switchToFreeCamera(); + } + // With touches, 3 touches will orbit around the point "in front of the camera". + if ( + (touchCount === 3 || inputManager.isKeyPressed(O_KEY)) && + this.isFreeCamera() + ) { + const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level. + this.switchToOrbitAroundZ0(maxDistance); + } + + this.orbitCameraControl.step(); + this.freeCameraControl.step(); + + if (this._hadChanged && !this._hasChanged) { + this._sendCameraState(); + } + this._hadChanged = this._hasChanged; + this._hasChanged = false; + } + + onHasCameraChanged() { + this._hasChanged = true; + } + + updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void { + this.getActiveCamera().updateCamera(currentScene, layer); + } + + zoomBy(zoomInFactor: float): void { + this.getActiveCamera().zoomBy(zoomInFactor); + this.onHasCameraChanged(); + } + + setZoom(zoom: float): void { + const distance = this._getCameraZFromZoom(zoom); + this.switchToOrbitAroundPosition(this.getAnchorX(), this.getAnchorY(), 0); + this.resetRotationToTopDown(); + this.setOrbitDistance(distance); + this.onHasCameraChanged(); + } + + getAnchorX(): float { + return this.getActiveCamera().getAnchorX(); + } + getAnchorY(): float { + return this.getActiveCamera().getAnchorY(); + } + getAnchorZ(): float { + return this.getActiveCamera().getAnchorZ(); + } + + zoomToInitialPosition(visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) { + const runtimeGame = this.editor.getRuntimeGame(); + this.zoomToFitArea( + { + minX: 0, + minY: 0, + minZ: 0, + maxX: runtimeGame.getOriginalWidth(), + maxY: runtimeGame.getOriginalHeight(), + maxZ: 0, + }, + visibleScreenArea, + 0.1 + ); + } + + zoomToFitObjects( + objects: Array, + visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }, + margin: float + ) { + if (objects.length === 0) { + const runtimeGame = this.editor.getRuntimeGame(); + this.zoomToFitArea( + { + minX: 0, + minY: 0, + minZ: 0, + maxX: runtimeGame.getOriginalWidth(), + maxY: runtimeGame.getOriginalHeight(), + maxZ: 0, + }, + visibleScreenArea, + 0.1 + ); + } + let minX = Number.MAX_VALUE; + let minY = Number.MAX_VALUE; + let minZ = Number.MAX_VALUE; + let maxX = Number.MIN_VALUE; + let maxY = Number.MIN_VALUE; + let maxZ = Number.MIN_VALUE; + for (const object of objects) { + const aabb = object.getAABB(); + minX = Math.min(minX, aabb.min[0]); + minY = Math.min(minY, aabb.min[1]); + minZ = Math.min(minZ, is3D(object) ? object.getUnrotatedAABBMinZ() : 0); + maxX = Math.max(maxX, aabb.max[0]); + maxY = Math.max(maxY, aabb.max[1]); + maxZ = Math.max(maxZ, is3D(object) ? object.getUnrotatedAABBMaxZ() : 0); + } + this.zoomToFitArea( + { + minX, + minY, + minZ, + maxX, + maxY, + maxZ, + }, + visibleScreenArea, + margin + ); + } + + zoomToFitArea( + sceneArea: { + minX: number; + minY: number; + minZ: number; + maxX: number; + maxY: number; + maxZ: number; + }, + visibleScreenArea: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }, + margin: float + ) { + const sceneAreaWidth = sceneArea.maxX - sceneArea.minX; + const sceneAreaHeight = sceneArea.maxY - sceneArea.minY; + + const runtimeGame = this.editor.getRuntimeGame(); + const renderedWidth = runtimeGame.getGameResolutionWidth(); + const renderedHeight = runtimeGame.getGameResolutionHeight(); + const editorWidth = + (visibleScreenArea.maxX - visibleScreenArea.minX) * renderedWidth; + const editorHeight = + (visibleScreenArea.maxY - visibleScreenArea.minY) * renderedHeight; + const isContentWider = + editorWidth * sceneAreaHeight < sceneAreaWidth * editorHeight; + const zoom = + (1 - 2 * margin) * + (isContentWider + ? editorWidth / sceneAreaWidth + : editorHeight / sceneAreaHeight); + const distance = this._getCameraZFromZoom(zoom); + + const sceneAreaCenterX = (sceneArea.maxX + sceneArea.minX) / 2; + const sceneAreaCenterY = (sceneArea.maxY + sceneArea.minY) / 2; + + this.switchToOrbitAroundPosition( + sceneAreaCenterX, + sceneAreaCenterY, + sceneArea.minZ + ); + this.resetRotationToTopDown(); + this.setOrbitDistance(distance); + this.onHasCameraChanged(); + } + + /** + * Get the camera center Z position. + * + * @param zoom The camera zoom. + * @return The z position of the camera + */ + _getCameraZFromZoom = (zoom: float): float => { + const runtimeGame = this.editor.getRuntimeGame(); + // Set the camera so that it displays the whole PixiJS plane, as if it was a 2D rendering. + // The Z position is computed by taking the half height of the displayed rendering, + // and using the angle of the triangle defined by the field of view to compute the length + // of the triangle defining the distance between the camera and the rendering plane. + return ( + (0.5 * runtimeGame.getGameResolutionHeight()) / + zoom / + Math.tan(0.5 * gdjs.toRad(editorCameraFov)) + ); + }; + + getCameraRotation(): float { + return this.getActiveCamera().rotationAngle; + } + + getCameraState(): EditorCameraState { + return this.getActiveCamera()._getCameraState(); + } + + restoreCameraState(cameraState: EditorCameraState) { + if (cameraState.cameraMode === 'free') { + this.orbitCameraControl.setEnabled(false); + this.freeCameraControl.setEnabled(true); + this.freeCameraControl._restoreCameraState(cameraState); + } else { + this.freeCameraControl.setEnabled(false); + this.orbitCameraControl.setEnabled(true); + this.orbitCameraControl._restoreCameraState(cameraState); + } + this.onHasCameraChanged(); + } + + private _sendCameraState() { + const runtimeGame = this.editor.getRuntimeGame(); + const debuggerClient = runtimeGame._debuggerClient; + if (!debuggerClient) return; + debuggerClient.sendCameraState(this.getCameraState()); + } + } + + interface CameraControl { + step(): void; + updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void; + zoomBy(zoomInFactor: float): void; + resetRotationToTopDown(): void; + getAnchorX(): float; + getAnchorY(): float; + getAnchorZ(): float; + } + + class OrbitCameraControl implements CameraControl { + private _editorCamera: EditorCamera; + target: THREE.Vector3 = new THREE.Vector3(); + rotationAngle: float = 0; + elevationAngle: float = 90; + distance: float = 800; + private _isEnabled: boolean = true; + + private _lastCursorX: float = 0; + private _lastCursorY: float = 0; + private _wasMouseRightButtonPressed = false; + private _wasMouseMiddleButtonPressed = false; + + private _gestureActiveTouchIds: Array = []; + private _gestureLastCentroidX: float = 0; + private _gestureLastCentroidY: float = 0; + + constructor(editorCamera: EditorCamera) { + this._editorCamera = editorCamera; + } + + isEnabled(): boolean { + return this._isEnabled; + } + + setEnabled(isEnabled: boolean): void { + this._isEnabled = isEnabled; + this._editorCamera.onHasCameraChanged(); + } + + step(): void { + const runtimeGame = this._editorCamera.editor.getRuntimeGame(); + const inputManager = runtimeGame.getInputManager(); + if (this._isEnabled) { + // Right click: rotate the camera. + // Middle click: also rotate the camera. + if ( + (inputManager.isMouseButtonPressed(1) && + // The camera should not move the 1st frame + this._wasMouseRightButtonPressed) || + (inputManager.isMouseButtonPressed(2) && + // The camera should not move the 1st frame + this._wasMouseMiddleButtonPressed) + ) { + const xDelta = inputManager.getCursorX() - this._lastCursorX; + const yDelta = inputManager.getCursorY() - this._lastCursorY; + + const rotationSpeed = 0.2; + this.rotationAngle += xDelta * rotationSpeed; + this.elevationAngle += yDelta * rotationSpeed; + this._editorCamera.onHasCameraChanged(); + } + + // Mouse wheel: movement on the plane or forward/backward movement. + const wheelDeltaY = inputManager.getMouseWheelDelta(); + if (wheelDeltaY !== 0 && shouldZoom(inputManager)) { + this.distance = Math.max( + 10, + this.distance * Math.pow(2, -wheelDeltaY / 512) + ); + this._editorCamera.onHasCameraChanged(); + } + + // Movement with keyboard: zoom in/out. + if (isControlOrCmdPressed(inputManager)) { + if (inputManager.wasKeyJustPressed(EQUAL_KEY)) { + this.zoomBy(zoomInFactor); + } else if (inputManager.wasKeyJustPressed(MINUS_KEY)) { + this.zoomBy(zoomOutFactor); + } + } + + // Touch gestures + const touchIds = getCurrentTouchIdentifiers(inputManager); + const touchCount = touchIds.length; + + if (touchCount === 0) { + this._gestureActiveTouchIds = []; + } else if (!areSameTouchesSet(this._gestureActiveTouchIds, touchIds)) { + // Start or reinitialize gesture tracking + this._gestureActiveTouchIds = touchIds.slice(); + if (touchCount === 3) { + const centroid3 = getTouchesCentroid(inputManager); + this._gestureLastCentroidX = centroid3.x; + this._gestureLastCentroidY = centroid3.y; + } + } else { + // Process ongoing gesture + if (touchCount === 3) { + // Three-finger rotation: + // - adjust elevation angle from vertical movement of centroid + // - adjust rotation angle from horizontal movement of centroid + const centroid3 = getTouchesCentroid(inputManager); + const dx3 = centroid3.x - this._gestureLastCentroidX; + const dy3 = centroid3.y - this._gestureLastCentroidY; + if (dx3 !== 0) { + const tiltSpeed = 0.2; + this.rotationAngle += dx3 * tiltSpeed; + this._editorCamera.onHasCameraChanged(); + } + if (dy3 !== 0) { + const tiltSpeed = 0.2; + this.elevationAngle += dy3 * tiltSpeed; + if (this.elevationAngle < 5) this.elevationAngle = 5; + if (this.elevationAngle > 175) this.elevationAngle = 175; + this._editorCamera.onHasCameraChanged(); + } + this._gestureLastCentroidX = centroid3.x; + this._gestureLastCentroidY = centroid3.y; + } + } + } else { + // Reset gesture tracking when camera control is disabled. + this._gestureActiveTouchIds = []; + } + + this._wasMouseRightButtonPressed = inputManager.isMouseButtonPressed(1); + this._wasMouseMiddleButtonPressed = inputManager.isMouseButtonPressed(2); + this._lastCursorX = inputManager.getCursorX(); + this._lastCursorY = inputManager.getCursorY(); + } + + getAnchorX(): float { + return this.target.x; + } + + getAnchorY(): float { + return this.target.y; + } + + getAnchorZ(): float { + return this.target.z; + } + + private _getCameraForwardVector(): Point3D { + // Camera forward (from camera toward where it looks), unit length. + const cosYaw = Math.cos(gdjs.toRad(this.rotationAngle + 90)); + const sinYaw = Math.sin(gdjs.toRad(this.rotationAngle + 90)); + const cosEl = Math.cos(gdjs.toRad(this.elevationAngle)); + const sinEl = Math.sin(gdjs.toRad(this.elevationAngle)); + + const fwdX = -cosYaw * cosEl; + const fwdY = -sinYaw * cosEl; + const fwdZ = -sinEl; + + return [fwdX, fwdY, fwdZ]; + } + + getCameraX(): float { + const [fwdX, ,] = this._getCameraForwardVector(); + return this.target.x - this.distance * fwdX; + } + + getCameraY(): float { + const [, fwdY] = this._getCameraForwardVector(); + return this.target.y - this.distance * fwdY; + } + + getCameraZ(): float { + const [, , fwdZ] = this._getCameraForwardVector(); + return this.target.z - this.distance * fwdZ; + } + + orbitFromPositionAroundZ0( + x: float, + y: float, + z: float, + targetMaxDistance: float + ): void { + const [fwdX, fwdY, fwdZ] = this._getCameraForwardVector(); + + // Intersect ray P(t) = camera position + t * forward with plane z = 0: + // z + t*fwdZ = 0 => t = -z / fwdZ + let tPlane: number | null = null; + if (Math.abs(fwdZ) > 1e-6) { + const t = -z / fwdZ; + // Only keep intersections "in front" of the camera + if (t > 0) tPlane = t; + } + + // Choose distance along the ray: + // - If there is a valid intersection within targetMaxDistance, use it + // - Otherwise, clamp to targetMaxDistance + const distance = + tPlane !== null && tPlane <= targetMaxDistance + ? tPlane + : targetMaxDistance; + + // Target point = point ahead of camera along forward by distance + this.target.x = x + fwdX * distance; + this.target.y = y + fwdY * distance; + this.target.z = z + fwdZ * distance; + + // Distance so that orbit camera stays exactly at the specified position + this.distance = distance; + } + + updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void { + const layerName = layer.getName(); + layer.setCameraX(this.getCameraX()); + layer.setCameraY(this.getCameraY()); + setCameraZ(currentScene, this.getCameraZ(), layerName, 0); + setCameraRotationX(currentScene, 90 - this.elevationAngle, layerName, 0); + setCameraRotationY(currentScene, 0, layerName, 0); + layer.setCameraRotation(this.rotationAngle); + } + + zoomBy(zoomFactor: float): void { + // The distance is proportional to the inverse of the zoom. + this.distance /= zoomFactor; + this._editorCamera.onHasCameraChanged(); + } + + resetRotationToTopDown(): void { + this.rotationAngle = 0; + this.elevationAngle = 90; + this._editorCamera.onHasCameraChanged(); + } + + _getCameraState(): EditorCameraState { + return { + cameraMode: 'orbit', + positionX: this.target.x, + positionY: this.target.y, + positionZ: this.target.z, + rotationAngle: this.rotationAngle, + elevationAngle: this.elevationAngle, + distance: this.distance, + }; + } + + _restoreCameraState(cameraState: EditorCameraState): void { + if (cameraState.cameraMode !== 'orbit') { + return; + } + this.target.x = cameraState.positionX; + this.target.y = cameraState.positionY; + this.target.z = cameraState.positionZ; + this.rotationAngle = cameraState.rotationAngle; + this.elevationAngle = cameraState.elevationAngle; + this.distance = cameraState.distance; + this._editorCamera.onHasCameraChanged(); + } + } + + class FreeCameraControl implements CameraControl { + private _editorCamera: EditorCamera; + position: THREE.Vector3 = new THREE.Vector3(); + rotationAngle: float = 0; + elevationAngle: float = 30; + private _isEnabled: boolean = true; + private _euler: THREE.Euler = new THREE.Euler(0, 0, 0, 'ZYX'); + private _rotationMatrix: THREE.Matrix4 = new THREE.Matrix4(); + + private _lastCursorX: float = 0; + private _lastCursorY: float = 0; + private _wasMouseRightButtonPressed = false; + + // Touch gesture state + private _gestureActiveTouchIds: Array = []; + private _gestureLastCentroidX: float = 0; + private _gestureLastCentroidY: float = 0; + private _gestureLastDistance: float = 0; + + constructor(editorCamera: EditorCamera) { + this._editorCamera = editorCamera; + } + + isEnabled(): boolean { + return this._isEnabled; + } + + setEnabled(isEnabled: boolean): void { + this._isEnabled = isEnabled; + this._editorCamera.onHasCameraChanged(); + } + + step(): void { + const runtimeGame = this._editorCamera.editor.getRuntimeGame(); + const inputManager = runtimeGame.getInputManager(); + if (this._isEnabled) { + const { right, up, forward } = this.getCameraVectors(); + + const moveCameraByVector = (vector: THREE.Vector3, scale: number) => { + this.position.x += vector.x * scale; + this.position.y += vector.y * scale; + this.position.z += vector.z * scale; + this._editorCamera.onHasCameraChanged(); + }; + + // Mouse wheel: movement on the plane or forward/backward movement. + const wheelDeltaY = inputManager.getMouseWheelDelta(); + const wheelDeltaX = inputManager.getMouseWheelDeltaX(); + if (wheelDeltaY !== 0 && shouldZoom(inputManager)) { + moveCameraByVector(forward, wheelDeltaY); + } else if ( + wheelDeltaY !== 0 && + shouldScrollHorizontally(inputManager) + ) { + moveCameraByVector(right, wheelDeltaY / 5); + } else if (wheelDeltaX !== 0 || wheelDeltaY !== 0) { + moveCameraByVector(up, wheelDeltaY / 5); + moveCameraByVector(right, wheelDeltaX / 5); + } + + // Touch gestures + const touchIds = getCurrentTouchIdentifiers(inputManager); + const touchCount = touchIds.length; + + if (touchCount === 0) { + this._gestureActiveTouchIds = []; + } else if (!areSameTouchesSet(this._gestureActiveTouchIds, touchIds)) { + // Start or reinitialize gesture tracking + this._gestureActiveTouchIds = touchIds.slice(); + if (touchCount === 2) { + const centroid = getTouchesCentroid(inputManager); + this._gestureLastCentroidX = centroid.x; + this._gestureLastCentroidY = centroid.y; + this._gestureLastDistance = getTouchesDistance(inputManager); + } + } else { + // Process ongoing gesture + if (touchCount === 2) { + // Pan: move on the camera plane by centroid delta + const centroid = getTouchesCentroid(inputManager); + const dx = (centroid.x - this._gestureLastCentroidX) * 5; + const dy = (centroid.y - this._gestureLastCentroidY) * 5; + if (dx !== 0 || dy !== 0) { + moveCameraByVector(up, dy); + moveCameraByVector(right, -dx); + this._gestureLastCentroidX = centroid.x; + this._gestureLastCentroidY = centroid.y; + } + + // Pinch: zoom forward/backward based on distance delta + const dist = getTouchesDistance(inputManager); + const pinchDelta = (dist - this._gestureLastDistance) * 10; + if (pinchDelta !== 0) { + moveCameraByVector(forward, pinchDelta); + this._gestureLastDistance = dist; + } + } + } + + // Movement with the keyboard: + // Either arrow keys (move in the camera plane) or WASD ("FPS move" + Q/E for up/down). + const moveSpeed = isShiftPressed(inputManager) ? 48 : 6; + + if ( + !isControlOrCmdPressed(inputManager) && + !isAltPressed(inputManager) + ) { + if (inputManager.isKeyPressed(LEFT_KEY)) { + moveCameraByVector(right, -moveSpeed); + } + if (inputManager.isKeyPressed(RIGHT_KEY)) { + moveCameraByVector(right, moveSpeed); + } + if (inputManager.isKeyPressed(UP_KEY)) { + moveCameraByVector(up, moveSpeed); + } + if (inputManager.isKeyPressed(DOWN_KEY)) { + moveCameraByVector(up, -moveSpeed); + } + // Forward/back + if (inputManager.isKeyPressed(W_KEY)) { + moveCameraByVector(forward, moveSpeed); + } + if (inputManager.isKeyPressed(S_KEY)) { + moveCameraByVector(forward, -moveSpeed); + } + + // Left/right (strafe) + if (inputManager.isKeyPressed(A_KEY)) { + moveCameraByVector(right, -moveSpeed); + } + if (inputManager.isKeyPressed(D_KEY)) { + moveCameraByVector(right, moveSpeed); + } + + // Up/down + if (inputManager.isKeyPressed(Q_KEY)) { + moveCameraByVector(up, -moveSpeed); + } + if (inputManager.isKeyPressed(E_KEY)) { + moveCameraByVector(up, moveSpeed); + } + } + + // Movement with keyboard: zoom in/out. + if (isControlOrCmdPressed(inputManager)) { + if (inputManager.wasKeyJustPressed(EQUAL_KEY)) { + this.zoomBy(zoomInFactor); + } else if (inputManager.wasKeyJustPressed(MINUS_KEY)) { + this.zoomBy(zoomOutFactor); + } + } + + // Space + click: move the camera on its plane. + // Shift + Wheel click: same. + if ( + (isSpacePressed(inputManager) && + inputManager.isMouseButtonPressed(0)) || + (isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2)) + ) { + const xDelta = this._lastCursorX - inputManager.getCursorX(); + const yDelta = this._lastCursorY - inputManager.getCursorY(); + moveCameraByVector(up, -yDelta); + moveCameraByVector(right, xDelta); + } + + // Right click: rotate the camera. + if ( + inputManager.isMouseButtonPressed(1) && + // The camera should not move the 1st frame + this._wasMouseRightButtonPressed + ) { + const xDelta = inputManager.getCursorX() - this._lastCursorX; + const yDelta = inputManager.getCursorY() - this._lastCursorY; + + const rotationSpeed = 0.2; + this.rotationAngle += xDelta * rotationSpeed; + this.elevationAngle += yDelta * rotationSpeed; + this._editorCamera.onHasCameraChanged(); + } + } else { + // Reset gesture tracking when camera control is disabled. + this._gestureActiveTouchIds = []; + } + this._wasMouseRightButtonPressed = inputManager.isMouseButtonPressed(1); + this._lastCursorX = inputManager.getCursorX(); + this._lastCursorY = inputManager.getCursorY(); + } + + moveForward(distanceDelta: number) { + const { forward } = this.getCameraVectors(); + + const moveCameraByVector = (vector: THREE.Vector3, scale: number) => { + this.position.x += vector.x * scale; + this.position.y += vector.y * scale; + this.position.z += vector.z * scale; + this._editorCamera.onHasCameraChanged(); + }; + + moveCameraByVector(forward, distanceDelta); + } + + updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void { + const layerName = layer.getName(); + layer.setCameraX(this.position.x); + layer.setCameraY(this.position.y); + setCameraZ(currentScene, this.position.z, layerName, 0); + setCameraRotationX(currentScene, 90 - this.elevationAngle, layerName, 0); + setCameraRotationY(currentScene, 0, layerName, 0); + layer.setCameraRotation(this.rotationAngle); + } + + private getCameraVectors() { + this._euler.x = gdjs.toRad(90 - this.elevationAngle); + this._euler.z = gdjs.toRad(this.rotationAngle); + this._rotationMatrix.makeRotationFromEuler(this._euler); + + // threeCamera.matrixWorld is a 4x4. In Three.js, the columns correspond to: + // [ right.x, up.x, forwardNeg.x, pos.x + // right.y, up.y, forwardNeg.y, pos.y + // right.z, up.z, forwardNeg.z, pos.z + // 0, 0, 0, 1 ] + // + // By default, a Three.js camera looks down the -Z axis, so the "forward" axis + // in the matrix is actually the negative Z column. We'll call it "forward" below. + const elements = this._rotationMatrix.elements; + + // Local right axis in world space: + const right = new THREE.Vector3(elements[0], elements[1], elements[2]); + // Local forward axis in world space (note we take the negative of that column). + const forward = new THREE.Vector3( + elements[8], + elements[9], + -elements[10] + ); + + // Local up axis in world space: orthogonal to both right and forward. + const up = new THREE.Vector3().crossVectors(forward, right); + + // Normalize them, just in case (they should generally be unit vectors). + right.normalize(); + up.normalize(); + forward.normalize(); + + return { right, up, forward }; + } + + getAnchorX(): float { + return this.position.x; + } + + getAnchorY(): float { + return this.position.y; + } + + getAnchorZ(): float { + return this.position.z; + } + + zoomBy(zoomInFactor: float): void { + this.moveForward(zoomInFactor > 1 ? 200 : -200); + this._editorCamera.onHasCameraChanged(); + } + + resetRotationToTopDown(): void { + this.rotationAngle = 0; + this.elevationAngle = 90; + this._editorCamera.onHasCameraChanged(); + } + + _getCameraState(): EditorCameraState { + return { + cameraMode: 'free', + positionX: this.position.x, + positionY: this.position.y, + positionZ: this.position.z, + rotationAngle: this.rotationAngle, + elevationAngle: this.elevationAngle, + distance: 0, + }; + } + + _restoreCameraState(cameraState: EditorCameraState): void { + if (cameraState.cameraMode !== 'free') { + return; + } + this.position.x = cameraState.positionX; + this.position.y = cameraState.positionY; + this.position.z = cameraState.positionZ; + this.rotationAngle = cameraState.rotationAngle; + this.elevationAngle = cameraState.elevationAngle; + this._editorCamera.onHasCameraChanged(); + } + } + + const getCameraZ = ( + runtimeScene: RuntimeScene, + layerName: string, + cameraIndex: integer + ): float => { + return gdjs.scene3d.camera + ? gdjs.scene3d.camera.getCameraZ(runtimeScene, layerName, cameraIndex) + : 0; + }; + + const setCameraZ = ( + runtimeScene: RuntimeScene, + z: float, + layerName: string, + cameraIndex: integer + ) => { + if (gdjs.scene3d.camera) { + gdjs.scene3d.camera.setCameraZ(runtimeScene, z, layerName, cameraIndex); + } + }; + + const setCameraRotationX = ( + runtimeScene: RuntimeScene, + angle: float, + layerName: string, + cameraIndex: integer + ) => { + if (gdjs.scene3d.camera) { + gdjs.scene3d.camera.setCameraRotationX( + runtimeScene, + angle, + layerName, + cameraIndex + ); + } + }; + + const setCameraRotationY = ( + runtimeScene: RuntimeScene, + angle: float, + layerName: string, + cameraIndex: integer + ) => { + if (gdjs.scene3d.camera) { + gdjs.scene3d.camera.setCameraRotationY( + runtimeScene, + angle, + layerName, + cameraIndex + ); + } + }; + + class ObjectSelectionBoxHelper { + object: gdjs.RuntimeObject; + dummyObject3DForObject2D: THREE.Object3D | null = null; + boxHelper: THREE.BoxHelper; + container: THREE.Group; + + constructor(object: gdjs.RuntimeObject) { + this.object = object; + + let threeObject = object.get3DRendererObject(); + if (!threeObject) { + threeObject = new THREE.Group(); + threeObject.add( + new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + ) + ); + this.dummyObject3DForObject2D = threeObject; + } + // Use a group to invert the Y-axis as the GDevelop Y axis is inverted + // compared to Three.js. This is somehow necessary because the position + // of the BoxHelper is always (0, 0, 0) and the geometry is hard to manipulate. + this.container = new THREE.Group(); + this.container.rotation.order = 'ZYX'; + this.container.scale.y = -1; + this.boxHelper = new THREE.BoxHelper(threeObject, '#f2a63c'); + this.boxHelper.rotation.order = 'ZYX'; + this.boxHelper.material.depthTest = false; + this.boxHelper.material.fog = false; + this.container.add(this.boxHelper); + } + + update() { + if (this.dummyObject3DForObject2D) { + this.dummyObject3DForObject2D.position.set( + this.object.getCenterXInScene(), + -this.object.getCenterYInScene(), + 0 + ); + this.dummyObject3DForObject2D.scale.set( + this.object.getWidth() + 2, + this.object.getHeight() + 2, + 0 + ); + } + this.boxHelper.update(); + } + + removeFromParent() { + this.container.removeFromParent(); + } + + setLayer(layer: number): void { + this.boxHelper.layers.set(layer); + } + + setColor(color: THREE.ColorRepresentation) { + this.boxHelper.material.color.set(color); + this.boxHelper.material.needsUpdate = true; + } + } + + /** + * A 3D object placeholder that is used as a fall back when the object type + * is not known. + */ + class UnknownRuntimeObject extends gdjs.RuntimeObject3D { + _renderer: UnknownRuntimeObjectRenderer; + + constructor( + instanceContainer: gdjs.RuntimeInstanceContainer, + objectData: gdjs.Object3DData + ) { + super(instanceContainer, objectData); + this._renderer = new UnknownRuntimeObjectRenderer( + this, + instanceContainer + ); + } + + override getRenderer(): gdjs.RuntimeObject3DRenderer { + return this._renderer; + } + + override onDestroyed(): void { + super.onDestroyed(); + this._renderer.onDestroyed(); + } + } + gdjs.registerObject('', UnknownRuntimeObject); + + class UnknownRuntimeObjectRenderer extends gdjs.RuntimeObject3DRenderer { + private _threeObject: THREE.Mesh; + + constructor( + runtimeObject: UnknownRuntimeObject, + instanceContainer: gdjs.RuntimeInstanceContainer + ) { + const cube = new THREE.Mesh( + new THREE.BoxGeometry(), + runtimeObject + .getInstanceContainer() + .getGame() + .getImageManager() + .getThreeMaterial('', { + useTransparentTexture: false, + forceBasicMaterial: true, + vertexColors: false, + }) + ); + super(runtimeObject, instanceContainer, cube); + this._threeObject = cube; + this.updateSize(); + this.updatePosition(); + this.updateRotation(); + } + + onDestroyed(): void { + this._threeObject.removeFromParent(); + this._threeObject.geometry.dispose(); + } + } +} diff --git a/GDJS/Runtime/InGameEditor/MoveIcon.svg b/GDJS/Runtime/InGameEditor/MoveIcon.svg new file mode 100644 index 0000000000..9c4ff45d05 --- /dev/null +++ b/GDJS/Runtime/InGameEditor/MoveIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/GDJS/Runtime/InGameEditor/OrbitCameraIcon.svg b/GDJS/Runtime/InGameEditor/OrbitCameraIcon.svg new file mode 100644 index 0000000000..919f467c09 --- /dev/null +++ b/GDJS/Runtime/InGameEditor/OrbitCameraIcon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/GDJS/Runtime/InGameEditor/ResizeIcon.svg b/GDJS/Runtime/InGameEditor/ResizeIcon.svg new file mode 100644 index 0000000000..2fce6acf94 --- /dev/null +++ b/GDJS/Runtime/InGameEditor/ResizeIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/GDJS/Runtime/InGameEditor/RotateIcon.svg b/GDJS/Runtime/InGameEditor/RotateIcon.svg new file mode 100644 index 0000000000..a5d2db001b --- /dev/null +++ b/GDJS/Runtime/InGameEditor/RotateIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/GDJS/Runtime/Model3DManager.ts b/GDJS/Runtime/Model3DManager.ts index e8abe77cca..ca1865cda4 100644 --- a/GDJS/Runtime/Model3DManager.ts +++ b/GDJS/Runtime/Model3DManager.ts @@ -164,14 +164,17 @@ namespace gdjs { } unloadResource(resourceData: ResourceData): void { - const loadedThreeModel = this._loadedThreeModels.get(resourceData); + const loadedThreeModel = this._loadedThreeModels.getFromName( + resourceData.name + ); if (loadedThreeModel) { loadedThreeModel.scene.clear(); this._loadedThreeModels.delete(resourceData); } - const downloadedArrayBuffer = - this._downloadedArrayBuffers.get(resourceData); + const downloadedArrayBuffer = this._downloadedArrayBuffers.getFromName( + resourceData.name + ); if (downloadedArrayBuffer) { this._downloadedArrayBuffers.delete(resourceData); } diff --git a/GDJS/Runtime/ResourceLoader.ts b/GDJS/Runtime/ResourceLoader.ts index 74e8bf7e23..16fc5d6b75 100644 --- a/GDJS/Runtime/ResourceLoader.ts +++ b/GDJS/Runtime/ResourceLoader.ts @@ -82,6 +82,28 @@ namespace gdjs { } } + class InternalInGameEditorOnlySvgManager implements gdjs.ResourceManager { + async loadResource(resourceName: string): Promise { + // Nothing to do. + } + + async processResource(resourceName: string): Promise { + // Nothing to do. + } + + getResourceKinds(): Array { + return ['internal-in-game-editor-only-svg']; + } + + unloadResource(resourceData: ResourceData): void { + // Nothing to do. + } + + dispose(): void { + // Nothing to do. + } + } + /** * Pre-load resources of any kind needed for a game or a scene. */ @@ -124,6 +146,7 @@ namespace gdjs { private _bitmapFontManager: BitmapFontManager; private _spineAtlasManager: SpineAtlasManager | null = null; private _spineManager: SpineManager | null = null; + private _svgManager: InternalInGameEditorOnlySvgManager; /** * The name of the scene for which resources are currently being loaded. @@ -169,6 +192,7 @@ namespace gdjs { this._imageManager ); this._model3DManager = new gdjs.Model3DManager(this); + this._svgManager = new InternalInGameEditorOnlySvgManager(); // add spine related managers only if spine extension is used if (gdjs.SpineAtlasManager && gdjs.SpineManager) { @@ -189,6 +213,7 @@ namespace gdjs { this._jsonManager, this._bitmapFontManager, this._model3DManager, + this._svgManager, ]; if (this._spineAtlasManager) @@ -282,6 +307,27 @@ namespace gdjs { } } + async loadResources( + resourceNames: Array, + onProgress: (loadingCount: integer, totalCount: integer) => void + ): Promise { + let loadedCount = 0; + await processAndRetryIfNeededWithPromisePool( + resourceNames, + maxForegroundConcurrency, + maxAttempt, + async (resourceName) => { + const resource = this._resources.get(resourceName); + if (resource) { + await this._loadResource(resource); + await this._processResource(resource); + } + loadedCount++; + onProgress(loadedCount, this._resources.size); + } + ); + } + /** * Load the resources that are needed to launch the first scene. */ @@ -551,6 +597,23 @@ namespace gdjs { // TODO: mark the scene as unloaded so it's not automatically loaded again eagerly. } + /** + * To be called when hot-reloading resources. + */ + unloadAllResources(): void { + debugLogger.log(`Unloading of all resources was requested.`); + for (const resource of this._resources.values()) { + const resourceManager = this._resourceManagersMap.get(resource.kind); + if (resourceManager) { + resourceManager.unloadResource(resource); + } + } + for (const sceneLoadingState of this._sceneLoadingStates.values()) { + sceneLoadingState.status = 'not-loaded'; + } + debugLogger.log(`Unloading of all resources finished.`); + } + /** * Put a given scene at the end of the queue. * @@ -652,6 +715,9 @@ namespace gdjs { * the resource (this can be for example a token needed to access the resource). */ getFullUrl(url: string) { + if (this._runtimeGame.isInGameEdition()) { + url = addSearchParameterToUrl(url, 'cache', '' + Date.now()); + } const { gdevelopResourceToken } = this._runtimeGame._options; if (!gdevelopResourceToken) return url; diff --git a/GDJS/Runtime/RuntimeInstanceContainer.ts b/GDJS/Runtime/RuntimeInstanceContainer.ts index 211be1a36e..20c4c32790 100644 --- a/GDJS/Runtime/RuntimeInstanceContainer.ts +++ b/GDJS/Runtime/RuntimeInstanceContainer.ts @@ -6,6 +6,19 @@ namespace gdjs { const logger = new gdjs.Logger('RuntimeInstanceContainer'); + const unknownObjectData = { + name: '', + type: '', + variables: [], + behaviors: [], + effects: [], + content: { + width: 32, + height: 32, + depth: 32, + }, + }; + /** * A container of object instances rendered on screen. */ @@ -42,7 +55,10 @@ namespace gdjs { _debugDrawShowPointsNames: boolean = false; _debugDrawShowCustomPoints: boolean = false; - constructor() { + /** + * @param runtimeGame The game associated to this scene. + */ + constructor(runtimeGame: gdjs.RuntimeGame) { this._initialBehaviorSharedData = new Hashtable(); this._instances = new Hashtable(); this._instancesCache = new Hashtable(); @@ -50,6 +66,10 @@ namespace gdjs { this._objectsCtor = new Hashtable(); this._layers = new Hashtable(); this._orderedLayers = []; + if (runtimeGame.isInGameEdition()) { + // Register an UnknownRuntimeObject to use when the object doesn't exist. + this.registerObject(unknownObjectData); + } } /** @@ -579,6 +599,14 @@ namespace gdjs { this._cacheOrClearRemovedInstances(); } + _updateObjectsForInGameEditor() { + const allInstancesList = this.getAdhocListOfAllInstances(); + for (let i = 0, len = allInstancesList.length; i < len; ++i) { + const obj = allInstancesList[i]; + obj.update(this); + } + } + /** * Call each behavior stepPostEvents method. */ @@ -616,7 +644,7 @@ namespace gdjs { getObjects(name: string): gdjs.RuntimeObject[] { if (!this._instances.containsKey(name)) { logger.info( - 'RuntimeScene.getObjects: No instances called "' + + 'RuntimeInstanceContainer.getObjects: No instances called "' + name + '"! Adding it.' ); @@ -636,8 +664,13 @@ namespace gdjs { !this._objectsCtor.containsKey(objectName) || !this._objects.containsKey(objectName) ) { - // There is no such object in this container. - return null; + if (this.getGame().isInGameEdition()) { + // Fallback on the UnknownRuntimeObject. + objectName = ''; + } else { + // There is no such object in this container. + return null; + } } const objectData = this._objects.get(objectName); diff --git a/GDJS/Runtime/RuntimeLayer.ts b/GDJS/Runtime/RuntimeLayer.ts index 9698133cbf..eae6d0827c 100644 --- a/GDJS/Runtime/RuntimeLayer.ts +++ b/GDJS/Runtime/RuntimeLayer.ts @@ -55,13 +55,14 @@ namespace gdjs { _timeScale: float = 1; _defaultZOrder: integer = 0; _hidden: boolean; - _initialEffectsData: Array; + _initialLayerData: LayerData; // TODO EBO Don't store scene layer related data in layers used by custom objects. // (both these 3D settings and the lighting layer properties below). _initialCamera3DFieldOfView: float; _initialCamera3DFarPlaneDistance: float; _initialCamera3DNearPlaneDistance: float; + _initialCamera2DPlaneMaxDrawingDistance: float; _runtimeScene: gdjs.RuntimeInstanceContainer; _effectsManager: gdjs.EffectsManager; @@ -94,7 +95,9 @@ namespace gdjs { layerData.camera3DNearPlaneDistance || 0.1; this._initialCamera3DFarPlaneDistance = layerData.camera3DFarPlaneDistance || 2000; - this._initialEffectsData = layerData.effects || []; + this._initialCamera2DPlaneMaxDrawingDistance = + layerData.camera2DPlaneMaxDrawingDistance || 5000; + this._initialLayerData = layerData; this._runtimeScene = instanceContainer; this._effectsManager = instanceContainer.getGame().getEffectsManager(); this._isLightingLayer = layerData.isLightingLayer; @@ -491,6 +494,9 @@ namespace gdjs { getInitialCamera3DFarPlaneDistance(): float { return this._initialCamera3DFarPlaneDistance; } + getInitialCamera2DPlaneMaxDrawingDistance(): float { + return this._initialCamera2DPlaneMaxDrawingDistance; + } /** * Return the initial effects data for the layer. Only to @@ -498,7 +504,7 @@ namespace gdjs { * @deprecated */ getInitialEffectsData(): EffectData[] { - return this._initialEffectsData; + return this._initialLayerData.effects || []; } /** @@ -696,7 +702,9 @@ namespace gdjs { * @return true if it is a lighting layer, false otherwise. */ isLightingLayer(): boolean { - return this._isLightingLayer; + return ( + this._isLightingLayer && !this._runtimeScene.getGame().isInGameEdition() + ); } } } diff --git a/GDJS/Runtime/debugger-client/InGameDebugger.tsx b/GDJS/Runtime/debugger-client/InGameDebugger.tsx index 72a9de37bb..06c9031183 100644 --- a/GDJS/Runtime/debugger-client/InGameDebugger.tsx +++ b/GDJS/Runtime/debugger-client/InGameDebugger.tsx @@ -1,5 +1,8 @@ namespace gdjs { - /** A minimal utility to define DOM elements. */ + /** + * A minimal utility to define DOM elements. + * Also copied in InGameEditor.tsx. + */ function h( tag: K, attrs: { diff --git a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts index 553fd2c30d..10edfaace1 100644 --- a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts @@ -107,8 +107,12 @@ namespace gdjs { exception: Error, runtimeGame: gdjs.RuntimeGame ) => { - const sceneNames = runtimeGame.getSceneStack().getAllSceneNames(); - const currentScene = runtimeGame.getSceneStack().getCurrentScene(); + const currentScene = runtimeGame.isInGameEdition() + ? runtimeGame.getInGameEditor()?.getCurrentScene() + : runtimeGame.getSceneStack().getCurrentScene(); + const sceneNames = runtimeGame.isInGameEdition() + ? [currentScene?.getName()] + : runtimeGame.getSceneStack().getAllSceneNames(); return { type: 'javascript-uncaught-exception', exception, @@ -116,6 +120,7 @@ namespace gdjs { playerId: runtimeGame.getPlayerId(), sessionId: runtimeGame.getSessionId(), isPreview: runtimeGame.isPreview(), + isInGameEdition: runtimeGame.isInGameEdition(), gdevelop: { previewContext: runtimeGame.getAdditionalOptions().previewContext, isNativeMobileApp: runtimeGame.getAdditionalOptions().nativeMobileApp, @@ -233,42 +238,261 @@ namespace gdjs { protected handleCommand(data: any) { const that = this; const runtimeGame = this._runtimegame; + const inGameEditor = runtimeGame.getInGameEditor(); if (!data || !data.command) { // Not a command that's meant to be handled by the debugger, return silently to // avoid polluting the console. return; } - if (data.command === 'play') { - runtimeGame.pause(false); - } else if (data.command === 'pause') { - runtimeGame.pause(true); - that.sendRuntimeGameDump(); - } else if (data.command === 'refresh') { - that.sendRuntimeGameDump(); - } else if (data.command === 'set') { - that.set(data.path, data.newValue); - } else if (data.command === 'call') { - that.call(data.path, data.args); - } else if (data.command === 'profiler.start') { - runtimeGame.startCurrentSceneProfiler(function (stoppedProfiler) { - that.sendProfilerOutput( - stoppedProfiler.getFramesAverageMeasures(), - stoppedProfiler.getStats() + try { + if (data.command === 'play') { + runtimeGame.pause(false); + } else if (data.command === 'pause') { + runtimeGame.pause(true); + 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') { + that.call(data.path, data.args); + } else if (data.command === 'profiler.start') { + runtimeGame.startCurrentSceneProfiler(function (stoppedProfiler) { + that.sendProfilerOutput( + stoppedProfiler.getFramesAverageMeasures(), + stoppedProfiler.getStats() + ); + that.sendProfilerStopped(); + }); + that.sendProfilerStarted(); + } else if (data.command === 'profiler.stop') { + runtimeGame.stopCurrentSceneProfiler(); + } else if (data.command === 'hotReload') { + const runtimeGameOptions: RuntimeGameOptions = + data.payload.runtimeGameOptions; + if ( + (runtimeGameOptions.initialRuntimeGameStatus?.isInGameEdition || + false) === runtimeGame.isInGameEdition() + ) { + this._hasLoggedUncaughtException = false; + that._hotReloader + .hotReload({ + projectData: data.payload.projectData, + runtimeGameOptions, + shouldReloadResources: + data.payload.shouldReloadResources || false, + }) + .then((logs) => { + that.sendHotReloaderLogs(logs); + }); + } + } else if (data.command === 'hotReloadObjects') { + if (inGameEditor) { + const editedInstanceContainer = + inGameEditor.getEditedInstanceContainer(); + if (editedInstanceContainer) { + that._hotReloader.hotReloadRuntimeSceneObjects( + data.payload.updatedObjects, + editedInstanceContainer + ); + } + } + } else if (data.command === 'hotReloadLayers') { + if (inGameEditor) { + const editedInstanceContainer = + inGameEditor.getEditedInstanceContainer(); + if (editedInstanceContainer) { + inGameEditor.onLayersDataChange( + data.payload.layers, + data.payload.areEffectsHidden + ); + that._hotReloader.hotReloadRuntimeSceneLayers( + data.payload.layers, + editedInstanceContainer + ); + // Apply `areEffectsHidden` to all the layers of the project data. + // It avoids inconsistency when switching scene later on. + // We do it after `hotReloadRuntimeSceneLayers` because it relies + // on the differences with old project data. + inGameEditor.setEffectsHiddenInEditor( + data.payload.areEffectsHidden + ); + } + } + } else if (data.command === 'setBackgroundColor') { + if (inGameEditor) { + const editedInstanceContainer = + inGameEditor.getEditedInstanceContainer(); + if (editedInstanceContainer) { + const backgroundColor = data.payload.backgroundColor; + if ( + backgroundColor && + editedInstanceContainer instanceof gdjs.RuntimeScene + ) { + const sceneData = runtimeGame.getSceneData( + editedInstanceContainer.getScene().getName() + ); + if (sceneData) { + editedInstanceContainer._backgroundColor = + gdjs.rgbToHexNumber( + backgroundColor[0], + backgroundColor[1], + backgroundColor[2] + ); + sceneData.r = backgroundColor[0]; + sceneData.v = backgroundColor[1]; + sceneData.b = backgroundColor[2]; + } + } + } + } + } else if (data.command === 'hotReloadAllInstances') { + if (inGameEditor) { + const editedInstanceContainer = + inGameEditor.getEditedInstanceContainer(); + if (editedInstanceContainer) { + that._hotReloader.hotReloadRuntimeInstances( + inGameEditor.getEditedInstanceDataList(), + data.payload.instances, + editedInstanceContainer + ); + } + } + } else if (data.command === 'switchForInGameEdition') { + if (!this._runtimegame.isInGameEdition()) return; + + const sceneName = data.sceneName || null; + const eventsBasedObjectType = data.eventsBasedObjectType || null; + if (!sceneName && !eventsBasedObjectType) { + logger.warn( + 'No scene name specified, switchForInGameEdition aborted' + ); + return; + } + if (inGameEditor) { + const wasPaused = this._runtimegame.isPaused(); + this._runtimegame.pause(true); + inGameEditor.switchToSceneOrVariant( + data.editorId || null, + sceneName, + data.externalLayoutName || null, + eventsBasedObjectType, + data.eventsBasedObjectVariantName || null, + data.editorCamera3D || null + ); + this._runtimegame.pause(wasPaused); + } + } else if (data.command === 'setVisibleStatus') { + if (inGameEditor) { + inGameEditor.setVisibleStatus(data.visible); + } + } else if (data.command === 'updateInstances') { + if (inGameEditor) { + inGameEditor.reloadInstances(data.payload.instances); + } + } else if (data.command === 'addInstances') { + if (inGameEditor) { + inGameEditor.addInstances(data.payload.instances); + inGameEditor.setSelectedObjects( + data.payload.instances.map((instance) => instance.persistentUuid) + ); + if (data.payload.moveUnderCursor) { + inGameEditor.moveSelectionUnderCursor(); + } + } + } else if (data.command === 'deleteSelection') { + if (inGameEditor) { + inGameEditor.deleteSelection(); + } + } else if (data.command === 'dragNewInstance') { + const gameCoords = runtimeGame + .getRenderer() + .convertPageToGameCoords(data.x, data.y); + runtimeGame + .getInputManager() + .onMouseMove(gameCoords[0], gameCoords[1]); + + if (inGameEditor) + inGameEditor.dragNewInstance({ + name: data.name, + dropped: data.dropped, + isAltPressed: data.isAltPressed, + }); + } else if (data.command === 'cancelDragNewInstance') { + if (inGameEditor) inGameEditor.cancelDragNewInstance(); + } else if (data.command === 'setInGameEditorSettings') { + if (inGameEditor && data.payload?.inGameEditorSettings) { + inGameEditor.setInGameEditorSettings( + data.payload.inGameEditorSettings + ); + } + } else if (data.command === 'setInstancesEditorSettings') { + if (inGameEditor) + inGameEditor.updateInstancesEditorSettings( + data.payload.instancesEditorSettings + ); + } else if (data.command === 'zoomToInitialPosition') { + if (inGameEditor) { + inGameEditor.zoomToInitialPosition(data.payload.visibleScreenArea); + } + } else if (data.command === 'zoomToFitContent') { + if (inGameEditor) { + inGameEditor.zoomToFitContent(data.payload.visibleScreenArea); + } + } else if (data.command === 'setSelectedLayer') { + if (inGameEditor) { + inGameEditor.setSelectedLayerName(data.payload.layerName); + } + } else if (data.command === 'zoomToFitSelection') { + if (inGameEditor) { + inGameEditor.zoomToFitSelection(data.payload.visibleScreenArea); + } + } else if (data.command === 'zoomBy') { + if (inGameEditor) { + inGameEditor.zoomBy(data.payload.zoomFactor); + } + } else if (data.command === 'setZoom') { + if (inGameEditor) { + inGameEditor.setZoom(data.payload.zoom); + } + } else if (data.command === 'setSelectedInstances') { + if (inGameEditor) { + inGameEditor.setSelectedObjects(data.payload.instanceUuids); + } + } else if (data.command === 'centerViewOnLastSelectedInstance') { + if (inGameEditor) { + // TODO: use data.payload.visibleScreenArea + inGameEditor.centerViewOnLastSelectedInstance(); + } + } else if (data.command === 'updateInnerArea') { + if (inGameEditor) { + inGameEditor.updateInnerArea( + data.payload.areaMinX, + data.payload.areaMinY, + data.payload.areaMinZ, + data.payload.areaMaxX, + data.payload.areaMaxY, + data.payload.areaMaxZ + ); + } + } else if (data.command === 'getSelectionAABB') { + if (inGameEditor) { + this.sendSelectionAABB(data.messageId); + } + } 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. + this.launchHardReload(); + } else { + logger.info( + 'Unknown command "' + data.command + '" received by the debugger.' ); - that.sendProfilerStopped(); - }); - that.sendProfilerStarted(); - } else if (data.command === 'profiler.stop') { - runtimeGame.stopCurrentSceneProfiler(); - } else if (data.command === 'hotReload') { - that._hotReloader.hotReload().then((logs) => { - that.sendHotReloaderLogs(logs); - }); - } else { - logger.info( - 'Unknown command "' + data.command + '" received by the debugger.' - ); + } + } catch (error) { + this.onUncaughtException(error); } } @@ -330,9 +554,12 @@ namespace gdjs { } onUncaughtException(exception: Error): void { - logger.error('Uncaught exception: ' + exception); + logger.error('Uncaught exception: ', exception, exception.stack); - this._inGameDebugger.setUncaughtException(exception); + const runtimeGame = this._runtimegame; + if (!runtimeGame.isInGameEdition()) { + this._inGameDebugger.setUncaughtException(exception); + } if (!this._hasLoggedUncaughtException) { // Only log an uncaught exception once, to avoid spamming the debugger server @@ -435,6 +662,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. */ @@ -515,7 +756,10 @@ namespace gdjs { this._sendMessage( circularSafeStringify({ command: 'hotReloader.logs', - payload: logs, + payload: { + isInGameEdition: this._runtimegame.isInGameEdition(), + logs, + }, }) ); } @@ -544,26 +788,173 @@ namespace gdjs { ); } - /** - * Callback called when the game is paused. - */ - sendGamePaused(): void { + sendInstanceChanges(changes: { + isSendingBackSelectionForDefaultSize: boolean; + updatedInstances: Array; + addedInstances: Array; + selectedInstances: Array; + removedInstances: Array; + objectNameToEdit: string | null; + }): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } this._sendMessage( circularSafeStringify({ - command: 'game.paused', - payload: null, + command: 'updateInstances', + editorId: inGameEditor.getEditorId(), + payload: changes, }) ); } - /** - * Callback called when the game is resumed. - */ - sendGameResumed(): void { + sendOpenContextMenu(cursorX: float, cursorY: float): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } this._sendMessage( circularSafeStringify({ - command: 'game.resumed', - payload: null, + command: 'openContextMenu', + editorId: inGameEditor.getEditorId(), + payload: { cursorX, cursorY }, + }) + ); + } + + sendCameraState(cameraState: EditorCameraState): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'setCameraState', + editorId: inGameEditor.getEditorId(), + payload: cameraState, + }) + ); + } + + sendUndo(): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'undo', + editorId: inGameEditor.getEditorId(), + payload: {}, + }) + ); + } + + sendRedo(): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'redo', + editorId: inGameEditor.getEditorId(), + payload: {}, + }) + ); + } + + sendCopy(): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'copy', + editorId: inGameEditor.getEditorId(), + payload: {}, + }) + ); + } + + sendPaste(): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'paste', + editorId: inGameEditor.getEditorId(), + payload: {}, + }) + ); + } + + sendCut(): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'cut', + editorId: inGameEditor.getEditorId(), + payload: {}, + }) + ); + } + + sendKeyboardShortcut(keyEventLike: { + keyCode: number; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + this._sendMessage( + circularSafeStringify({ + command: 'handleKeyboardShortcutFromInGameEditor', + editorId: inGameEditor.getEditorId(), + payload: keyEventLike, + }) + ); + } + + sendSelectionAABB(messageId: number): void { + const inGameEditor = this._runtimegame.getInGameEditor(); + if (!inGameEditor) { + return; + } + const selectionAABB = inGameEditor.getSelectionAABB(); + this._sendMessage( + circularSafeStringify({ + command: 'selectionAABB', + editorId: inGameEditor.getEditorId(), + messageId, + payload: selectionAABB + ? { + minX: selectionAABB.min[0], + minY: selectionAABB.min[1], + minZ: selectionAABB.min[2], + maxX: selectionAABB.max[0], + maxY: selectionAABB.max[1], + maxZ: selectionAABB.max[2], + } + : { + minX: 0, + minY: 0, + minZ: 0, + maxX: 0, + maxY: 0, + maxZ: 0, + }, }) ); } @@ -587,5 +978,43 @@ namespace gdjs { }) ); } + + launchHardReload(): void { + try { + const reloadUrl = new URL(location.href); + + // Construct the initial status to be restored. + const initialRuntimeGameStatus = + this._runtimegame.getAdditionalOptions().initialRuntimeGameStatus; + // We use empty strings to avoid `null` to become `"null"`. + const runtimeGameStatus: RuntimeGameStatus = { + editorId: initialRuntimeGameStatus?.editorId || '', + isPaused: this._runtimegame.isPaused(), + isInGameEdition: this._runtimegame.isInGameEdition(), + sceneName: initialRuntimeGameStatus?.sceneName || '', + injectedExternalLayoutName: + initialRuntimeGameStatus?.injectedExternalLayoutName || '', + skipCreatingInstancesFromScene: + initialRuntimeGameStatus?.skipCreatingInstancesFromScene || false, + eventsBasedObjectType: + initialRuntimeGameStatus?.eventsBasedObjectType || '', + eventsBasedObjectVariantName: + initialRuntimeGameStatus?.eventsBasedObjectVariantName || '', + editorCamera3D: this._runtimegame.getInGameEditor()?.getCameraState(), + }; + + 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(); + } + } } } diff --git a/GDJS/Runtime/debugger-client/hot-reloader.ts b/GDJS/Runtime/debugger-client/hot-reloader.ts index 13101b72fb..faa0bcb267 100644 --- a/GDJS/Runtime/debugger-client/hot-reloader.ts +++ b/GDJS/Runtime/debugger-client/hot-reloader.ts @@ -144,18 +144,30 @@ namespace gdjs { }); } - hotReload(): Promise { + async hotReload({ + shouldReloadResources, + projectData: newProjectData, + runtimeGameOptions: newRuntimeGameOptions, + }: { + shouldReloadResources: boolean; + projectData: ProjectData; + runtimeGameOptions: RuntimeGameOptions; + }): Promise { logger.info('Hot reload started'); + const wasPaused = this._runtimeGame.isPaused(); this._runtimeGame.pause(true); this._logs = []; // Save old data of the project, to be used to compute // the difference between the old and new project data: - const oldProjectData: ProjectData = gdjs.projectData; + gdjs.projectData = newProjectData; - const oldScriptFiles = gdjs.runtimeGameOptions - .scriptFiles as RuntimeGameOptionsScriptFile[]; + const oldRuntimeGameOptions = gdjs.runtimeGameOptions; + gdjs.runtimeGameOptions = newRuntimeGameOptions; + + const oldScriptFiles = + oldRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[]; oldScriptFiles.forEach((scriptFile) => { this._alreadyLoadedScriptFiles[scriptFile.path] = true; @@ -167,76 +179,103 @@ namespace gdjs { gdjs.behaviorsTypes.items[behaviorTypeName]; } - // Reload projectData and runtimeGameOptions stored by convention in data.js: - return this._reloadScript('data.js').then(() => { - const newProjectData: ProjectData = gdjs.projectData; + if (gdjs.inAppTutorialMessage) { + gdjs.inAppTutorialMessage.displayInAppTutorialMessage( + this._runtimeGame, + newRuntimeGameOptions.inAppTutorialMessageInPreview, + newRuntimeGameOptions.inAppTutorialMessagePositionInPreview || '' + ); + } - const newRuntimeGameOptions: RuntimeGameOptions = - gdjs.runtimeGameOptions; + const newScriptFiles = + newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[]; + const shouldGenerateScenesEventsCode = + !!newRuntimeGameOptions.shouldGenerateScenesEventsCode; + const shouldReloadLibraries = + !!newRuntimeGameOptions.shouldReloadLibraries; - if (gdjs.inAppTutorialMessage) { - gdjs.inAppTutorialMessage.displayInAppTutorialMessage( - this._runtimeGame, - newRuntimeGameOptions.inAppTutorialMessageInPreview, - newRuntimeGameOptions.inAppTutorialMessagePositionInPreview || '' + // 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 { + if (shouldReloadLibraries) { + await this.reloadScriptFiles( + newProjectData, + oldScriptFiles, + newScriptFiles, + shouldGenerateScenesEventsCode ); } - - 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). - return this.reloadScriptFiles( - newProjectData, - oldScriptFiles, - newScriptFiles, - projectDataOnlyExport - ) - .then(() => { - const changedRuntimeBehaviors = - this._computeChangedRuntimeBehaviors( - oldBehaviorConstructors, - gdjs.behaviorsTypes.items - ); - return this._hotReloadRuntimeGame( - oldProjectData, - newProjectData, - changedRuntimeBehaviors, - this._runtimeGame + const newRuntimeGameStatus = + newRuntimeGameOptions.initialRuntimeGameStatus; + if ( + newRuntimeGameStatus && + newRuntimeGameStatus.editorId && + newRuntimeGameStatus.isInGameEdition + ) { + if (shouldReloadResources) { + // Unloading all resources will force them to be loaded again, + // which is sufficient for ensuring they are up-to-date as + // resources will be loaded with a 'cache bursting' parameter. + this._runtimeGame._resourcesLoader.unloadAllResources(); + } + // The editor don't need to hot-reload the current scene because the + // editor always stays in the initial state. + this._runtimeGame.setProjectData(newProjectData); + await this._runtimeGame.loadFirstAssetsAndStartBackgroundLoading( + newRuntimeGameStatus.sceneName || newProjectData.firstLayout, + () => {} + ); + const inGameEditor = this._runtimeGame.getInGameEditor(); + if (inGameEditor) { + inGameEditor.onProjectDataChange(newProjectData); + await inGameEditor.switchToSceneOrVariant( + newRuntimeGameStatus.editorId || null, + newRuntimeGameStatus.sceneName, + newRuntimeGameStatus.injectedExternalLayoutName, + newRuntimeGameStatus.eventsBasedObjectType, + newRuntimeGameStatus.eventsBasedObjectVariantName, + newRuntimeGameStatus.editorCamera3D || null ); - }) - .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 + - '\n' + - error.stack, - }); - } - }) - .then(() => { - logger.info( - 'Hot reload finished with logs:', - this._logs.map((log) => '\n' + log.kind + ': ' + log.message) - ); - this._runtimeGame.pause(false); - return this._logs; + } + } else { + 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 + + '\n' + + error.stack, + }); + } + } + + logger.info( + 'Hot reload finished with logs:', + this._logs.map((log) => '\n' + log.kind + ': ' + log.message) + ); + this._runtimeGame.pause(wasPaused); + return this._logs; } _computeChangedRuntimeBehaviors( @@ -281,12 +320,12 @@ namespace gdjs { newProjectData: ProjectData, oldScriptFiles: RuntimeGameOptionsScriptFile[], newScriptFiles: RuntimeGameOptionsScriptFile[], - projectDataOnlyExport: boolean + shouldGenerateScenesEventsCode: boolean ): Promise { const reloadPromises: Array> = []; // Reload events, only if they were exported. - if (!projectDataOnlyExport) { + if (shouldGenerateScenesEventsCode) { newProjectData.layouts.forEach((_layoutData, index) => { reloadPromises.push(this._reloadScript('code' + index + '.js')); }); @@ -326,7 +365,7 @@ namespace gdjs { )[0]; // A file may be removed because of a partial preview. - if (!newScriptFile && !projectDataOnlyExport) { + if (!newScriptFile && !shouldGenerateScenesEventsCode) { this._logs.push({ kind: 'warning', message: 'Script file ' + oldScriptFile.path + ' was removed.', @@ -694,6 +733,16 @@ namespace gdjs { runtimeScene.setEventsGeneratedCodeFunction(newLayoutData); } + /** + * Add the children object data into every custom object data. + * + * At the runtime, this is done at the object instantiation. + * For hot-reloading, it's done before hands to optimize. + * + * @param projectData The project data + * @param objectDatas The object datas to modify + * @returns + */ static resolveCustomObjectConfigurations( projectData: ProjectData, objectDatas: ObjectData[] @@ -717,27 +766,43 @@ namespace gdjs { if (!eventsBasedObjectData) { return objectData; } - const customObjectConfiguration = objectData as ObjectData & CustomObjectConfiguration; + const eventsBasedObjectVariantData = + gdjs.RuntimeGame._getEventsBasedObjectVariantData( + eventsBasedObjectData, + customObjectConfiguration.variant + ); + // Apply the legacy children configuration overriding if any. const mergedChildObjectDataList = - customObjectConfiguration.childrenContent - ? eventsBasedObjectData.objects.map((objectData) => ({ - ...objectData, - ...customObjectConfiguration.childrenContent[objectData.name], - })) + gdjs.CustomRuntimeObjectInstanceContainer.hasChildrenConfigurationOverriding( + customObjectConfiguration, + eventsBasedObjectVariantData + ) + ? eventsBasedObjectData.objects.map((objectData) => + customObjectConfiguration.childrenContent + ? { + ...objectData, + ...customObjectConfiguration.childrenContent[ + objectData.name + ], + } + : objectData + ) : eventsBasedObjectData.objects; const mergedObjectConfiguration = { - ...eventsBasedObjectData, - ...objectData, - // ObjectData doesn't have an `objects` attribute. + // ObjectData doesn't have an `objects` nor `instances` attribute. // This is a small optimization to avoid to create an // InstanceContainerData for each instance to hot-reload their inner // scene (see `_hotReloadRuntimeInstanceContainer` call from // `_hotReloadRuntimeSceneInstances`). + ...eventsBasedObjectData, + ...eventsBasedObjectVariantData, objects: mergedChildObjectDataList, + // It must be the last one to ensure the object name won't be overridden. + ...objectData, }; return mergedObjectConfiguration; }); @@ -751,6 +816,12 @@ namespace gdjs { changedRuntimeBehaviors: ChangedRuntimeBehavior[], runtimeInstanceContainer: gdjs.RuntimeInstanceContainer ): void { + if (!oldLayoutData.objects || !newLayoutData.objects) { + // It can happen when `hotReloadRuntimeInstances` is executed. + // `hotReloadRuntimeInstances` doesn't resolve the custom objects + // because it can only modify the 1st level of instances. + return; + } const oldObjectDataList = HotReloader.resolveCustomObjectConfigurations( oldProjectData, oldLayoutData.objects @@ -921,16 +992,62 @@ namespace gdjs { return; } + hotReloadRuntimeSceneObjects( + updatedObjects: Array, + // runtimeInstanceContainer gives an access as a map. + runtimeInstanceContainer: gdjs.RuntimeInstanceContainer + ): void { + const oldObjects: Array = updatedObjects.map( + (objectData) => + runtimeInstanceContainer._objects.get(objectData.name) || null + ); + + const projectData: ProjectData = this._runtimeGame._data; + const newObjectDataList = HotReloader.resolveCustomObjectConfigurations( + projectData, + updatedObjects + ); + + this._hotReloadRuntimeSceneObjects( + oldObjects, + newObjectDataList, + runtimeInstanceContainer + ); + // Update the GameData + for (let index = 0; index < updatedObjects.length; index++) { + const oldObjectData = oldObjects[index]; + // When the object is new, the hot-reload call `registerObject` + // so `_objects` is already updated. + if (oldObjectData) { + // In gdjs.CustomRuntimeObjectInstanceContainer.loadFrom, object can + // be registered with a different instance from the ProjectData. This + // is only done for children of a custom object with a children overriding. + // In the case of the editor, the fake custom object used for editing + // variants has no children overriding (see + // gdjs.RuntimeGame._createSceneWithCustomObject). + // Thus, the oldObjectData is always the one from the ProjectData. + HotReloader.assignOrDelete(oldObjectData, updatedObjects[index]); + } else { + console.warn( + `Can't update object data for "${updatedObjects[index].name}" because it doesn't exist.` + ); + } + } + } + _hotReloadRuntimeSceneObjects( - oldObjects: ObjectData[], + oldObjects: Array, newObjects: ObjectData[], runtimeInstanceContainer: gdjs.RuntimeInstanceContainer ): void { oldObjects.forEach((oldObjectData) => { + if (!oldObjectData) { + return; + } const name = oldObjectData.name; - const newObjectData = newObjects.filter( + const newObjectData = newObjects.find( (objectData) => objectData.name === name - )[0]; + ); // Note: if an object is renamed in the editor, it will be considered as removed, // and the new object name as a new object to register. @@ -952,9 +1069,9 @@ namespace gdjs { }); newObjects.forEach((newObjectData) => { const name = newObjectData.name; - const oldObjectData = oldObjects.filter( - (layerData) => layerData.name === name - )[0]; + const oldObjectData = oldObjects.find( + (layerData) => layerData && layerData.name === name + ); if ( (!oldObjectData || oldObjectData.type !== newObjectData.type) && !runtimeInstanceContainer.isObjectRegistered(name) @@ -1192,6 +1309,31 @@ namespace gdjs { ); } + hotReloadRuntimeSceneLayers( + newLayers: LayerData[], + runtimeInstanceContainer: gdjs.RuntimeInstanceContainer + ): void { + const layerNames = []; + runtimeInstanceContainer.getAllLayerNames(layerNames); + const oldLayers = layerNames.map((layerName) => + runtimeInstanceContainer.hasLayer(layerName) + ? runtimeInstanceContainer.getLayer(layerName)._initialLayerData + : null + ); + this._hotReloadRuntimeSceneLayers( + oldLayers.filter(Boolean) as LayerData[], + newLayers, + runtimeInstanceContainer + ); + // Update the GameData + for (let index = 0; index < newLayers.length; index++) { + const oldLayer = oldLayers[index]; + if (oldLayer) { + HotReloader.assignOrDelete(oldLayer, newLayers[index]); + } + } + } + _hotReloadRuntimeSceneLayers( oldLayers: LayerData[], newLayers: LayerData[], @@ -1273,6 +1415,8 @@ namespace gdjs { newLayer.effects, runtimeLayer ); + + runtimeLayer._initialLayerData = newLayer; } _hotReloadRuntimeLayerEffects( @@ -1357,6 +1501,28 @@ namespace gdjs { } } + hotReloadRuntimeInstances( + oldInstances: InstanceData[], + newInstances: InstanceData[], + runtimeInstanceContainer: RuntimeInstanceContainer + ): void { + const projectData: ProjectData = gdjs.projectData; + const objects: Array = []; + runtimeInstanceContainer._objects.values(objects); + projectData.layouts; + this._hotReloadRuntimeSceneInstances( + projectData, + projectData, + [], + objects, + objects, + oldInstances, + newInstances, + runtimeInstanceContainer + ); + gdjs.copyArray(newInstances, oldInstances); + } + _hotReloadRuntimeSceneInstances( oldProjectData: ProjectData, newProjectData: ProjectData, @@ -1423,6 +1589,9 @@ namespace gdjs { ); } else { // Reload objects that were created at runtime. + // This is a subset of what is done by `_hotReloadRuntimeInstance`. + // Since the instance doesn't exist in the editor, it's properties + // can't be updated, only the object changes are applied. // Update variables this._hotReloadVariablesContainer( @@ -1431,6 +1600,7 @@ namespace gdjs { runtimeObject.getVariables() ); + // Update the content of custom object if (runtimeObject instanceof gdjs.CustomRuntimeObject) { const childrenInstanceContainer = runtimeObject.getChildrenContainer(); @@ -1443,15 +1613,18 @@ namespace gdjs { CustomObjectConfiguration & InstanceContainerData; - // Reload the content of custom objects that were created at runtime. - this._hotReloadRuntimeInstanceContainer( - oldProjectData, - newProjectData, - oldCustomObjectData, - newCustomObjectData, - changedRuntimeBehaviors, - childrenInstanceContainer - ); + // Variant swapping is handled by `CustomRuntimeObject.updateFromObjectData`. + if (newCustomObjectData.variant === oldCustomObjectData.variant) { + // Reload the content of custom objects that were created at runtime. + this._hotReloadRuntimeInstanceContainer( + oldProjectData, + newProjectData, + oldCustomObjectData, + newCustomObjectData, + changedRuntimeBehaviors, + childrenInstanceContainer + ); + } } } } @@ -1513,22 +1686,16 @@ namespace gdjs { somethingChanged = true; } if (gdjs.Base3DHandler && gdjs.Base3DHandler.is3D(runtimeObject)) { - if (oldInstance.z !== newInstance.z && newInstance.z !== undefined) { - runtimeObject.setZ(newInstance.z); + if (oldInstance.z !== newInstance.z) { + runtimeObject.setZ(newInstance.z || 0); somethingChanged = true; } - if ( - oldInstance.rotationX !== newInstance.rotationX && - newInstance.rotationX !== undefined - ) { - runtimeObject.setRotationX(newInstance.rotationX); + if (oldInstance.rotationX !== newInstance.rotationX) { + runtimeObject.setRotationX(newInstance.rotationX || 0); somethingChanged = true; } - if ( - oldInstance.rotationY !== newInstance.rotationY && - newInstance.rotationY !== undefined - ) { - runtimeObject.setRotationY(newInstance.rotationY); + if (oldInstance.rotationY !== newInstance.rotationY) { + runtimeObject.setRotationY(newInstance.rotationY || 0); somethingChanged = true; } } @@ -1583,8 +1750,6 @@ namespace gdjs { } } if (runtimeObject instanceof gdjs.CustomRuntimeObject) { - const childrenInstanceContainer = runtimeObject.getChildrenContainer(); - // The `objects` attribute is already resolved by `resolveCustomObjectConfigurations()`. const oldCustomObjectData = oldObjectData as ObjectData & CustomObjectConfiguration & @@ -1593,14 +1758,19 @@ namespace gdjs { CustomObjectConfiguration & InstanceContainerData; - this._hotReloadRuntimeInstanceContainer( - oldProjectData, - newProjectData, - oldCustomObjectData, - newCustomObjectData, - changedRuntimeBehaviors, - childrenInstanceContainer - ); + // Variant swapping is handled by `CustomRuntimeObject.updateFromObjectData`. + if (newCustomObjectData.variant === oldCustomObjectData.variant) { + const childrenInstanceContainer = + runtimeObject.getChildrenContainer(); + this._hotReloadRuntimeInstanceContainer( + oldProjectData, + newProjectData, + oldCustomObjectData, + newCustomObjectData, + changedRuntimeBehaviors, + childrenInstanceContainer + ); + } } // Update variables @@ -1727,5 +1897,23 @@ namespace gdjs { // true if both NaN, false otherwise return a !== a && b !== b; } + + static assignOrDelete( + target: any, + source: any, + ignoreKeys: string[] = [] + ): void { + Object.assign(target, source); + for (const key in target) { + if (ignoreKeys.includes(key)) { + continue; + } + if (Object.prototype.hasOwnProperty.call(target, key)) { + if (source[key] === undefined) { + delete target[key]; + } + } + } + } } } diff --git a/GDJS/Runtime/debugger-client/websocket-debugger-client.ts b/GDJS/Runtime/debugger-client/websocket-debugger-client.ts index 6d7afd583b..51b1f77024 100644 --- a/GDJS/Runtime/debugger-client/websocket-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/websocket-debugger-client.ts @@ -56,6 +56,19 @@ namespace gdjs { }; this._ws.onclose = function close() { logger.info('Debugger connection closed'); + + if (that._runtimegame.isInGameEdition()) { + // Sometimes, for example if the editor is launched for a long time and the device goes to sleep, + // the WebSocket connection between the editor and the game is closed. When we are in in-game edition, + // we can't afford to lose the connection because it means the editor is unusable. + // In this case, we hard reload the game to re-establish a new connection. + setTimeout(() => { + logger.info( + 'Debugger connection closed while in in-game edition - this is suspicious so hard reloading to re-establish a new connection.' + ); + that.launchHardReload(); + }, 1000); + } }; this._ws.onerror = function errored(error) { logger.warn('Debugger client error:', error); diff --git a/GDJS/Runtime/debugger-client/window-message-debugger-client.ts b/GDJS/Runtime/debugger-client/window-message-debugger-client.ts index f94aaba828..5b1a612315 100644 --- a/GDJS/Runtime/debugger-client/window-message-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/window-message-debugger-client.ts @@ -11,7 +11,13 @@ namespace gdjs { constructor(runtimeGame: RuntimeGame) { super(runtimeGame); + // Opener is either the `opener` for popups, or the `parent` if the game + // is running as an iframe (notably: in-game edition). this._opener = window.opener || null; + if (!this._opener && window.parent !== window) { + this._opener = window.parent; + } + if (!this._opener) { logger.info("`window.opener` not existing, the debugger won't work."); return; diff --git a/GDJS/Runtime/fontfaceobserver-font-manager/fontfaceobserver-font-manager.ts b/GDJS/Runtime/fontfaceobserver-font-manager/fontfaceobserver-font-manager.ts index f0795f9ed8..8f0c4d74d3 100644 --- a/GDJS/Runtime/fontfaceobserver-font-manager/fontfaceobserver-font-manager.ts +++ b/GDJS/Runtime/fontfaceobserver-font-manager/fontfaceobserver-font-manager.ts @@ -207,7 +207,7 @@ namespace gdjs { } unloadResource(resourceData: ResourceData): void { - const resource = this._loadedFontFamily.get(resourceData); + const resource = this._loadedFontFamily.getFromName(resourceData.name); if (resource) { this._loadedFontFamily.delete(resourceData); } diff --git a/GDJS/Runtime/howler-sound-manager/howler-sound-manager.ts b/GDJS/Runtime/howler-sound-manager/howler-sound-manager.ts index db32c3ee07..5fb8a06476 100644 --- a/GDJS/Runtime/howler-sound-manager/howler-sound-manager.ts +++ b/GDJS/Runtime/howler-sound-manager/howler-sound-manager.ts @@ -476,6 +476,8 @@ namespace gdjs { _freeSounds: HowlerSound[] = []; // Sounds without an assigned channel. _freeMusics: HowlerSound[] = []; // Musics without an assigned channel. + _muteEverythingReasons: Set = new Set(); + /** Paused sounds or musics that should be played once the game is resumed. */ _pausedSounds: HowlerSound[] = []; _paused: boolean = false; @@ -960,6 +962,16 @@ namespace gdjs { this._cachedSpatialPosition = {}; } + muteEverything(reason: string): void { + this._muteEverythingReasons.add(reason); + this._updateGlobalVolume(); + } + + unmuteEverything(reason: string): void { + this._muteEverythingReasons.delete(reason); + this._updateGlobalVolume(); + } + setGlobalVolume(volume: float): void { this._globalVolume = volume; if (this._globalVolume > 100) { @@ -968,13 +980,21 @@ namespace gdjs { if (this._globalVolume < 0) { this._globalVolume = 0; } - Howler.volume(this._globalVolume / 100); + this._updateGlobalVolume(); } getGlobalVolume(): float { return this._globalVolume; } + private _updateGlobalVolume(): void { + if (this._muteEverythingReasons.size > 0) { + Howler.volume(0); + } else { + Howler.volume(this._globalVolume / 100); + } + } + clearAll() { Howler.stop(); @@ -1157,12 +1177,12 @@ namespace gdjs { } unloadResource(resourceData: ResourceData): void { - const musicRes = this._loadedMusics.get(resourceData); + const musicRes = this._loadedMusics.getFromName(resourceData.name); if (musicRes) { this.unloadAudio(resourceData.name, true); } - const soundRes = this._loadedSounds.get(resourceData); + const soundRes = this._loadedSounds.getFromName(resourceData.name); if (soundRes) { this.unloadAudio(resourceData.name, false); } diff --git a/GDJS/Runtime/inputmanager.ts b/GDJS/Runtime/inputmanager.ts index 2b8d61107a..9ffbe8bf4f 100644 --- a/GDJS/Runtime/inputmanager.ts +++ b/GDJS/Runtime/inputmanager.ts @@ -33,24 +33,28 @@ namespace gdjs { /** * The cursor X position (moved by mouse and touch events). */ - _cursorX: float = 0; + private _cursorX: float = 0; /** * The cursor Y position (moved by mouse and touch events). */ - _cursorY: float = 0; + private _cursorY: float = 0; /** * The mouse X position (only moved by mouse events). */ - _mouseX: float = 0; + private _mouseX: float = 0; /** * The mouse Y position (only moved by mouse events). */ - _mouseY: float = 0; - _isMouseInsideCanvas: boolean = true; - _mouseWheelDelta: float = 0; + private _mouseY: float = 0; + private _isMouseInsideCanvas: boolean = true; + private _wheelDeltaX: float = 0; + private _wheelDeltaY: float = 0; + private _wheelDeltaZ: float = 0; + // TODO Remove _touches when there is no longer SpritePanelButton 1.2.0 // extension in the wild. - _touches = { + // @ts-ignore + private _touches = { firstKey: (): string | number | null => { for (const key in this._mouseOrTouches.items) { // Exclude mouse key. @@ -61,22 +65,23 @@ namespace gdjs { return null; }, }; - _mouseOrTouches: Hashtable; + + private _mouseOrTouches: Hashtable; //Identifiers of the touches that started during/before the frame. - _startedTouches: Array = []; + private _startedTouches: Array = []; //Identifiers of the touches that ended during/before the frame. - _endedTouches: Array = []; - _touchSimulateMouse: boolean = true; + private _endedTouches: Array = []; + private _touchSimulateMouse: boolean = true; /** * @deprecated */ - _lastStartedTouchIndex = 0; + private _lastStartedTouchIndex = 0; /** * @deprecated */ - _lastEndedTouchIndex = 0; + private _lastEndedTouchIndex = 0; constructor() { this._pressedKeys = new Hashtable(); @@ -96,7 +101,7 @@ namespace gdjs { * @param keyCode The raw key code * @param location The location */ - _getLocationAwareKeyCode( + static getLocationAwareKeyCode( keyCode: number, location: number | null | undefined ): integer { @@ -121,7 +126,7 @@ namespace gdjs { * @param location The location of the event. */ onKeyPressed(keyCode: number, location?: number): void { - const locationAwareKeyCode = this._getLocationAwareKeyCode( + const locationAwareKeyCode = InputManager.getLocationAwareKeyCode( keyCode, location ); @@ -138,7 +143,7 @@ namespace gdjs { * @param location The location of the event. */ onKeyReleased(keyCode: number, location?: number): void { - const locationAwareKeyCode = this._getLocationAwareKeyCode( + const locationAwareKeyCode = InputManager.getLocationAwareKeyCode( keyCode, location ); @@ -234,6 +239,14 @@ namespace gdjs { return false; } + exceptionallyGetAllJustPressedKeys(): number[] { + const result: string[] = []; + this._justPressedKeys.keys(result); + return result.map((locationAwareKeyCode) => + parseInt(locationAwareKeyCode, 10) + ); + } + /** * Should be called when the mouse is moved. * @@ -335,6 +348,19 @@ namespace gdjs { } } + /** + * Return true if any mouse button is pressed. + * @return true if any mouse button is pressed. + */ + anyMouseButtonPressed(): boolean { + for (const buttonCode in this._pressedMouseButtons) { + if (this._pressedMouseButtons[buttonCode]) { + return true; + } + } + return false; + } + _setMouseButtonPressed(buttonCode: number): void { this._pressedMouseButtons[buttonCode] = true; this._releasedMouseButtons[buttonCode] = false; @@ -380,17 +406,37 @@ namespace gdjs { /** * Should be called whenever the mouse wheel is used - * @param wheelDelta The mouse wheel delta + * @param wheelDeltaY The mouse wheel delta */ - onMouseWheel(wheelDelta: number): void { - this._mouseWheelDelta = wheelDelta; + onMouseWheel( + wheelDeltaY: number, + wheelDeltaX: number, + wheelDeltaZ: number + ): void { + this._wheelDeltaY = wheelDeltaY; + if (wheelDeltaX !== undefined) this._wheelDeltaX = wheelDeltaX; + if (wheelDeltaZ !== undefined) this._wheelDeltaZ = wheelDeltaZ; } /** - * Return the mouse wheel delta + * Return the mouse wheel delta on Y axis. */ getMouseWheelDelta(): float { - return this._mouseWheelDelta; + return this._wheelDeltaY; + } + + /** + * Return the mouse wheel delta on X axis. + */ + getMouseWheelDeltaX(): float { + return this._wheelDeltaX; + } + + /** + * Return the mouse wheel delta on Z axis. + */ + getMouseWheelDeltaZ(): float { + return this._wheelDeltaZ; } /** @@ -578,7 +624,9 @@ namespace gdjs { this._releasedKeys.clear(); this._justPressedKeys.clear(); this._releasedMouseButtons.length = 0; - this._mouseWheelDelta = 0; + this._wheelDeltaX = 0; + this._wheelDeltaY = 0; + this._wheelDeltaZ = 0; this._lastStartedTouchIndex = 0; this._lastEndedTouchIndex = 0; } diff --git a/GDJS/Runtime/jsonmanager.ts b/GDJS/Runtime/jsonmanager.ts index a4dd2b6354..c25d6ddb31 100644 --- a/GDJS/Runtime/jsonmanager.ts +++ b/GDJS/Runtime/jsonmanager.ts @@ -210,12 +210,12 @@ namespace gdjs { } unloadResource(resourceData: ResourceData): void { - const loadedJson = this._loadedJsons.get(resourceData); + const loadedJson = this._loadedJsons.getFromName(resourceData.name); if (loadedJson) { this._loadedJsons.delete(resourceData); } - const callback = this._callbacks.get(resourceData); + const callback = this._callbacks.getFromName(resourceData.name); if (callback) { this._callbacks.delete(resourceData); } diff --git a/GDJS/Runtime/pixi-renderers/ThreeAddons.js b/GDJS/Runtime/pixi-renderers/ThreeAddons.js index 93f0c869c5..265cec2b82 100644 --- a/GDJS/Runtime/pixi-renderers/ThreeAddons.js +++ b/GDJS/Runtime/pixi-renderers/ThreeAddons.js @@ -1 +1,2 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("three")):"function"==typeof define&&define.amd?define(["exports","three"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).THREE_ADDONS={},e.THREE)}(this,(function(e,t){"use strict";function r(e,r){if(r===t.TrianglesDrawMode)return console.warn("THREE.BufferGeometryUtils.toTrianglesDrawMode(): Geometry already defined as triangles."),e;if(r===t.TriangleFanDrawMode||r===t.TriangleStripDrawMode){let n=e.getIndex();if(null===n){const t=[],r=e.getAttribute("position");if(void 0===r)return console.error("THREE.BufferGeometryUtils.toTrianglesDrawMode(): Undefined position attribute. Processing not possible."),e;for(let e=0;e=2.0 are supported.")));const u=new J(s,{path:t||this.resourcePath||"",crossOrigin:this.crossOrigin,requestHeader:this.requestHeader,manager:this.manager,ktx2Loader:this.ktx2Loader,meshoptDecoder:this.meshoptDecoder});u.fileLoader.setRequestHeader(this.requestHeader);for(let e=0;e=0&&void 0===l[t]&&console.warn('THREE.GLTFLoader: Unknown extension "'+t+'".')}}u.setExtensions(i),u.setPlugins(l),u.parse(r,n)}parseAsync(e,t){const r=this;return new Promise((function(n,s){r.parse(e,t,n,s)}))}}function s(){let e={};return{get:function(t){return e[t]},add:function(t,r){e[t]=r},remove:function(t){delete e[t]},removeAll:function(){e={}}}}const o={KHR_BINARY_GLTF:"KHR_binary_glTF",KHR_DRACO_MESH_COMPRESSION:"KHR_draco_mesh_compression",KHR_LIGHTS_PUNCTUAL:"KHR_lights_punctual",KHR_MATERIALS_CLEARCOAT:"KHR_materials_clearcoat",KHR_MATERIALS_IOR:"KHR_materials_ior",KHR_MATERIALS_SHEEN:"KHR_materials_sheen",KHR_MATERIALS_SPECULAR:"KHR_materials_specular",KHR_MATERIALS_TRANSMISSION:"KHR_materials_transmission",KHR_MATERIALS_IRIDESCENCE:"KHR_materials_iridescence",KHR_MATERIALS_ANISOTROPY:"KHR_materials_anisotropy",KHR_MATERIALS_UNLIT:"KHR_materials_unlit",KHR_MATERIALS_VOLUME:"KHR_materials_volume",KHR_TEXTURE_BASISU:"KHR_texture_basisu",KHR_TEXTURE_TRANSFORM:"KHR_texture_transform",KHR_MESH_QUANTIZATION:"KHR_mesh_quantization",KHR_MATERIALS_EMISSIVE_STRENGTH:"KHR_materials_emissive_strength",EXT_MATERIALS_BUMP:"EXT_materials_bump",EXT_TEXTURE_WEBP:"EXT_texture_webp",EXT_TEXTURE_AVIF:"EXT_texture_avif",EXT_MESHOPT_COMPRESSION:"EXT_meshopt_compression",EXT_MESH_GPU_INSTANCING:"EXT_mesh_gpu_instancing"};class i{constructor(e){this.parser=e,this.name=o.KHR_LIGHTS_PUNCTUAL,this.cache={refs:{},uses:{}}}_markDefs(){const e=this.parser,t=this.parser.json.nodes||[];for(let r=0,n=t.length;r=0)throw new Error("THREE.GLTFLoader: setKTX2Loader must be called before loading KTX2 textures");return null}return t.loadTextureImage(e,s.source,o)}}class A{constructor(e){this.parser=e,this.name=o.EXT_TEXTURE_WEBP,this.isSupported=null}loadTexture(e){const t=this.name,r=this.parser,n=r.json,s=n.textures[e];if(!s.extensions||!s.extensions[t])return null;const o=s.extensions[t],i=n.images[o.source];let a=r.textureLoader;if(i.uri){const e=r.options.manager.getHandler(i.uri);null!==e&&(a=e)}return this.detectSupport().then((function(s){if(s)return r.loadTextureImage(e,o.source,a);if(n.extensionsRequired&&n.extensionsRequired.indexOf(t)>=0)throw new Error("THREE.GLTFLoader: WebP required by asset but unsupported.");return r.loadTexture(e)}))}detectSupport(){return this.isSupported||(this.isSupported=new Promise((function(e){const t=new Image;t.src="data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA",t.onload=t.onerror=function(){e(1===t.height)}}))),this.isSupported}}class T{constructor(e){this.parser=e,this.name=o.EXT_TEXTURE_AVIF,this.isSupported=null}loadTexture(e){const t=this.name,r=this.parser,n=r.json,s=n.textures[e];if(!s.extensions||!s.extensions[t])return null;const o=s.extensions[t],i=n.images[o.source];let a=r.textureLoader;if(i.uri){const e=r.options.manager.getHandler(i.uri);null!==e&&(a=e)}return this.detectSupport().then((function(s){if(s)return r.loadTextureImage(e,o.source,a);if(n.extensionsRequired&&n.extensionsRequired.indexOf(t)>=0)throw new Error("THREE.GLTFLoader: AVIF required by asset but unsupported.");return r.loadTexture(e)}))}detectSupport(){return this.isSupported||(this.isSupported=new Promise((function(e){const t=new Image;t.src="data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEDQgMgkQAAAAB8dSLfI=",t.onload=t.onerror=function(){e(1===t.height)}}))),this.isSupported}}class b{constructor(e){this.name=o.EXT_MESHOPT_COMPRESSION,this.parser=e}loadBufferView(e){const t=this.parser.json,r=t.bufferViews[e];if(r.extensions&&r.extensions[this.name]){const e=r.extensions[this.name],n=this.parser.getDependency("buffer",e.buffer),s=this.parser.options.meshoptDecoder;if(!s||!s.supported){if(t.extensionsRequired&&t.extensionsRequired.indexOf(this.name)>=0)throw new Error("THREE.GLTFLoader: setMeshoptDecoder must be called before loading compressed files");return null}return n.then((function(t){const r=e.byteOffset||0,n=e.byteLength||0,o=e.count,i=e.byteStride,a=new Uint8Array(t,r,n);return s.decodeGltfBufferAsync?s.decodeGltfBufferAsync(o,i,a,e.mode,e.filter).then((function(e){return e.buffer})):s.ready.then((function(){const t=new ArrayBuffer(o*i);return s.decodeGltfBuffer(new Uint8Array(t),o,i,a,e.mode,e.filter),t}))}))}return null}}class w{constructor(e){this.name=o.EXT_MESH_GPU_INSTANCING,this.parser=e}createNodeMesh(e){const r=this.parser.json,n=r.nodes[e];if(!n.extensions||!n.extensions[this.name]||void 0===n.mesh)return null;const s=r.meshes[n.mesh];for(const e of s.primitives)if(e.mode!==N.TRIANGLES&&e.mode!==N.TRIANGLE_STRIP&&e.mode!==N.TRIANGLE_FAN&&void 0!==e.mode)return null;const o=n.extensions[this.name].attributes,i=[],a={};for(const e in o)i.push(this.parser.getDependency("accessor",o[e]).then((t=>(a[e]=t,a[e]))));return i.length<1?null:(i.push(this.parser.createNodeMesh(e)),Promise.all(i).then((e=>{const r=e.pop(),n=r.isGroup?r.children:[r],s=e[0].count,o=[];for(const e of n){const r=new t.Matrix4,n=new t.Vector3,i=new t.Quaternion,l=new t.Vector3(1,1,1),c=new t.InstancedMesh(e.geometry,e.material,s);for(let e=0;e-1,i=o?navigator.userAgent.match(/Firefox\/([0-9]+)\./)[1]:-1),"undefined"==typeof createImageBitmap||n||o&&i<98?this.textureLoader=new t.TextureLoader(this.options.manager):this.textureLoader=new t.ImageBitmapLoader(this.options.manager),this.textureLoader.setCrossOrigin(this.options.crossOrigin),this.textureLoader.setRequestHeader(this.options.requestHeader),this.fileLoader=new t.FileLoader(this.options.manager),this.fileLoader.setResponseType("arraybuffer"),"use-credentials"===this.options.crossOrigin&&this.fileLoader.setWithCredentials(!0)}setExtensions(e){this.extensions=e}setPlugins(e){this.plugins=e}parse(e,t){const r=this,n=this.json,s=this.extensions;this.cache.removeAll(),this.nodeCache={},this._invokeAll((function(e){return e._markDefs&&e._markDefs()})),Promise.all(this._invokeAll((function(e){return e.beforeRoot&&e.beforeRoot()}))).then((function(){return Promise.all([r.getDependencies("scene"),r.getDependencies("animation"),r.getDependencies("camera")])})).then((function(t){const o={scene:t[0][n.scene||0],scenes:t[0],animations:t[1],cameras:t[2],asset:n.asset,parser:r,userData:{}};return X(s,o,n),V(o,n),Promise.all(r._invokeAll((function(e){return e.afterRoot&&e.afterRoot(o)}))).then((function(){e(o)}))})).catch(t)}_markDefs(){const e=this.json.nodes||[],t=this.json.skins||[],r=this.json.meshes||[];for(let r=0,n=t.length;r{const r=this.associations.get(e);null!=r&&this.associations.set(t,r);for(const[r,n]of e.children.entries())s(n,t.children[r])};return s(r,n),n.name+="_instance_"+e.uses[t]++,n}_invokeOne(e){const t=Object.values(this.plugins);t.push(this);for(let r=0;r=2&&m.setY(t,h[e*i+1]),i>=3&&m.setZ(t,h[e*i+2]),i>=4&&m.setW(t,h[e*i+3]),i>=5)throw new Error("THREE.GLTFLoader: Unsupported itemSize in sparse BufferAttribute.")}}return m}))}loadTexture(e){const t=this.json,r=this.options,n=t.textures[e].source,s=t.images[n];let o=this.textureLoader;if(s.uri){const e=r.manager.getHandler(s.uri);null!==e&&(o=e)}return this.loadTextureImage(e,n,o)}loadTextureImage(e,r,n){const s=this,o=this.json,i=o.textures[e],a=o.images[r],l=(a.uri||a.bufferView)+":"+i.sampler;if(this.textureCache[l])return this.textureCache[l];const c=this.loadImageSource(r,n).then((function(r){r.flipY=!1,r.name=i.name||a.name||"",""===r.name&&"string"==typeof a.uri&&!1===a.uri.startsWith("data:image/")&&(r.name=a.uri);const n=(o.samplers||{})[i.sampler]||{};return r.magFilter=F[n.magFilter]||t.LinearFilter,r.minFilter=F[n.minFilter]||t.LinearMipmapLinearFilter,r.wrapS=I[n.wrapS]||t.RepeatWrapping,r.wrapT=I[n.wrapT]||t.RepeatWrapping,s.associations.set(r,{textures:e}),r})).catch((function(){return null}));return this.textureCache[l]=c,c}loadImageSource(e,r){const n=this,s=this.json,o=this.options;if(void 0!==this.sourceCache[e])return this.sourceCache[e].then((e=>e.clone()));const i=s.images[e],a=self.URL||self.webkitURL;let l=i.uri||"",c=!1;if(void 0!==i.bufferView)l=n.getDependency("bufferView",i.bufferView).then((function(e){c=!0;const t=new Blob([e],{type:i.mimeType});return l=a.createObjectURL(t),l}));else if(void 0===i.uri)throw new Error("THREE.GLTFLoader: Image "+e+" is missing URI and bufferView");const u=Promise.resolve(l).then((function(e){return new Promise((function(n,s){let i=n;!0===r.isImageBitmapLoader&&(i=function(e){const r=new t.Texture(e);r.needsUpdate=!0,n(r)}),r.load(t.LoaderUtils.resolveURL(e,o.path),i,void 0,s)}))})).then((function(e){var t;return!0===c&&a.revokeObjectURL(l),e.userData.mimeType=i.mimeType||((t=i.uri).search(/\.jpe?g($|\?)/i)>0||0===t.search(/^data\:image\/jpeg/)?"image/jpeg":t.search(/\.webp($|\?)/i)>0||0===t.search(/^data\:image\/webp/)?"image/webp":"image/png"),e})).catch((function(e){throw console.error("THREE.GLTFLoader: Couldn't load texture",l),e}));return this.sourceCache[e]=u,u}assignTexture(e,t,r,n){const s=this;return this.getDependency("texture",r.index).then((function(i){if(!i)return null;if(void 0!==r.texCoord&&r.texCoord>0&&((i=i.clone()).channel=r.texCoord),s.extensions[o.KHR_TEXTURE_TRANSFORM]){const e=void 0!==r.extensions?r.extensions[o.KHR_TEXTURE_TRANSFORM]:void 0;if(e){const t=s.associations.get(i);i=s.extensions[o.KHR_TEXTURE_TRANSFORM].extendTexture(i,e),s.associations.set(i,t)}}return void 0!==n&&(i.colorSpace=n),e[t]=i,i}))}assignFinalMaterial(e){const r=e.geometry;let n=e.material;const s=void 0===r.attributes.tangent,o=void 0!==r.attributes.color,i=void 0===r.attributes.normal;if(e.isPoints){const e="PointsMaterial:"+n.uuid;let r=this.cache.get(e);r||(r=new t.PointsMaterial,t.Material.prototype.copy.call(r,n),r.color.copy(n.color),r.map=n.map,r.sizeAttenuation=!1,this.cache.add(e,r)),n=r}else if(e.isLine){const e="LineBasicMaterial:"+n.uuid;let r=this.cache.get(e);r||(r=new t.LineBasicMaterial,t.Material.prototype.copy.call(r,n),r.color.copy(n.color),r.map=n.map,this.cache.add(e,r)),n=r}if(s||o||i){let e="ClonedMaterial:"+n.uuid+":";s&&(e+="derivative-tangents:"),o&&(e+="vertex-colors:"),i&&(e+="flat-shading:");let t=this.cache.get(e);t||(t=n.clone(),o&&(t.vertexColors=!0),i&&(t.flatShading=!0),s&&(t.normalScale&&(t.normalScale.y*=-1),t.clearcoatNormalScale&&(t.clearcoatNormalScale.y*=-1)),this.cache.add(e,t),this.associations.set(t,this.associations.get(n))),n=t}e.material=n}getMaterialType(){return t.MeshStandardMaterial}loadMaterial(e){const r=this,n=this.json,s=this.extensions,i=n.materials[e];let a;const l={},c=[];if((i.extensions||{})[o.KHR_MATERIALS_UNLIT]){const e=s[o.KHR_MATERIALS_UNLIT];a=e.getMaterialType(),c.push(e.extendParams(l,i,r))}else{const n=i.pbrMetallicRoughness||{};if(l.color=new t.Color(1,1,1),l.opacity=1,Array.isArray(n.baseColorFactor)){const e=n.baseColorFactor;l.color.setRGB(e[0],e[1],e[2],t.LinearSRGBColorSpace),l.opacity=e[3]}void 0!==n.baseColorTexture&&c.push(r.assignTexture(l,"map",n.baseColorTexture,t.SRGBColorSpace)),l.metalness=void 0!==n.metallicFactor?n.metallicFactor:1,l.roughness=void 0!==n.roughnessFactor?n.roughnessFactor:1,void 0!==n.metallicRoughnessTexture&&(c.push(r.assignTexture(l,"metalnessMap",n.metallicRoughnessTexture)),c.push(r.assignTexture(l,"roughnessMap",n.metallicRoughnessTexture))),a=this._invokeOne((function(t){return t.getMaterialType&&t.getMaterialType(e)})),c.push(Promise.all(this._invokeAll((function(t){return t.extendMaterialParams&&t.extendMaterialParams(e,l)}))))}!0===i.doubleSided&&(l.side=t.DoubleSide);const u=i.alphaMode||z;if(u===j?(l.transparent=!0,l.depthWrite=!1):(l.transparent=!1,u===W&&(l.alphaTest=void 0!==i.alphaCutoff?i.alphaCutoff:.5)),void 0!==i.normalTexture&&a!==t.MeshBasicMaterial&&(c.push(r.assignTexture(l,"normalMap",i.normalTexture)),l.normalScale=new t.Vector2(1,1),void 0!==i.normalTexture.scale)){const e=i.normalTexture.scale;l.normalScale.set(e,e)}if(void 0!==i.occlusionTexture&&a!==t.MeshBasicMaterial&&(c.push(r.assignTexture(l,"aoMap",i.occlusionTexture)),void 0!==i.occlusionTexture.strength&&(l.aoMapIntensity=i.occlusionTexture.strength)),void 0!==i.emissiveFactor&&a!==t.MeshBasicMaterial){const e=i.emissiveFactor;l.emissive=(new t.Color).setRGB(e[0],e[1],e[2],t.LinearSRGBColorSpace)}return void 0!==i.emissiveTexture&&a!==t.MeshBasicMaterial&&c.push(r.assignTexture(l,"emissiveMap",i.emissiveTexture,t.SRGBColorSpace)),Promise.all(c).then((function(){const t=new a(l);return i.name&&(t.name=i.name),V(t,i),r.associations.set(t,{materials:e}),i.extensions&&X(s,t,i),t}))}createUniqueName(e){const r=t.PropertyBinding.sanitizeNodeName(e||"");return r in this.nodeNamesUsed?r+"_"+ ++this.nodeNamesUsed[r]:(this.nodeNamesUsed[r]=0,r)}loadGeometries(e){const r=this,n=this.extensions,s=this.primitiveCache;function i(e){return n[o.KHR_DRACO_MESH_COMPRESSION].decodePrimitive(e,r).then((function(t){return _(t,e,r)}))}const a=[];for(let n=0,l=e.length;n0&&K(f,i),f.name=n.createUniqueName(i.name||"mesh_"+e),V(f,i),d.extensions&&X(o,f,d),n.assignFinalMaterial(f),u.push(f)}for(let t=0,r=u.length;t1?new t.Group:1===r.length?r[0]:new t.Object3D,a!==r[0])for(let e=0,t=r.length;e{const r=new Map;for(const[e,n]of s.associations)(e instanceof t.Material||e instanceof t.Texture)&&r.set(e,n);return e.traverse((e=>{const t=s.associations.get(e);null!=t&&r.set(e,t)})),r})(o),o}))}_createAnimationTracks(e,r,n,s,o){const i=[],a=e.name?e.name:e.uuid,l=[];let c;switch(G[o.path]===G.weights?e.traverse((function(e){e.morphTargetInfluences&&l.push(e.name?e.name:e.uuid)})):l.push(a),G[o.path]){case G.weights:c=t.NumberKeyframeTrack;break;case G.rotation:c=t.QuaternionKeyframeTrack;break;case G.position:case G.scale:c=t.VectorKeyframeTrack;break;default:if(1===n.itemSize)c=t.NumberKeyframeTrack;else c=t.VectorKeyframeTrack}const u=void 0!==s.interpolation?k[s.interpolation]:t.InterpolateLinear,h=this._getArrayFromAccessor(n);for(let e=0,t=l.length;e{this.parse(e,r,s)}),n,s)}parse(e,r,n=(()=>{})){this.decodeDracoFile(e,r,null,null,t.SRGBColorSpace).catch(n)}decodeDracoFile(e,r,n,s,o=t.LinearSRGBColorSpace,i=(()=>{})){const a={attributeIDs:n||this.defaultAttributeIDs,attributeTypes:s||this.defaultAttributeTypes,useUniqueIDs:!!n,vertexColorSpace:o};return this.decodeGeometry(e,a).then(r).catch(i)}decodeGeometry(e,t){const r=JSON.stringify(t);if($.has(e)){const t=$.get(e);if(t.key===r)return t.promise;if(0===e.byteLength)throw new Error("THREE.DRACOLoader: Unable to re-decode a buffer with different settings. Buffer has already been transferred.")}let n;const s=this.workerNextTaskID++,o=e.byteLength,i=this._getWorker(s,o).then((r=>(n=r,new Promise(((r,o)=>{n._callbacks[s]={resolve:r,reject:o},n.postMessage({type:"decode",id:s,taskConfig:t,buffer:e},[e])}))))).then((e=>this._createGeometry(e.geometry)));return i.catch((()=>!0)).then((()=>{n&&s&&this._releaseTask(n,s)})),$.set(e,{key:r,promise:i}),i}_createGeometry(e){const r=new t.BufferGeometry;e.index&&r.setIndex(new t.BufferAttribute(e.index.array,1));for(let n=0;n{n.load(e,t,void 0,r)}))}preload(){return this._initDecoder(),this}_initDecoder(){if(this.decoderPending)return this.decoderPending;const e="object"!=typeof WebAssembly||"js"===this.decoderConfig.type,t=[];return e?t.push(this._loadLibrary("draco_decoder.js","text")):(t.push(this._loadLibrary("draco_wasm_wrapper.js","text")),t.push(this._loadLibrary("draco_decoder.wasm","arraybuffer"))),this.decoderPending=Promise.all(t).then((t=>{const r=t[0];e||(this.decoderConfig.wasmBinary=t[1]);const n=te.toString(),s=["/* draco decoder */",r,"","/* worker */",n.substring(n.indexOf("{")+1,n.lastIndexOf("}"))].join("\n");this.workerSourceURL=URL.createObjectURL(new Blob([s]))})),this.decoderPending}_getWorker(e,t){return this._initDecoder().then((()=>{if(this.workerPool.lengtht._taskLoad?-1:1}));const r=this.workerPool[this.workerPool.length-1];return r._taskCosts[e]=t,r._taskLoad+=t,r}))}_releaseTask(e,t){e._taskLoad-=e._taskCosts[t],delete e._callbacks[t],delete e._taskCosts[t]}debug(){console.log("Task load: ",this.workerPool.map((e=>e._taskLoad)))}dispose(){for(let e=0;e{const t=e.draco,i=new t.Decoder;try{const e=function(e,t,n,s){const o=s.attributeIDs,i=s.attributeTypes;let a,l;const c=t.GetEncodedGeometryType(n);if(c===e.TRIANGULAR_MESH)a=new e.Mesh,l=t.DecodeArrayToMesh(n,n.byteLength,a);else{if(c!==e.POINT_CLOUD)throw new Error("THREE.DRACOLoader: Unexpected geometry type.");a=new e.PointCloud,l=t.DecodeArrayToPointCloud(n,n.byteLength,a)}if(!l.ok()||0===a.ptr)throw new Error("THREE.DRACOLoader: Decoding failed: "+l.error_msg());const u={index:null,attributes:[]};for(const n in o){const l=self[i[n]];let c,h;if(s.useUniqueIDs)h=o[n],c=t.GetAttributeByUniqueId(a,h);else{if(h=t.GetAttributeId(a,e[o[n]]),-1===h)continue;c=t.GetAttribute(a,h)}const d=r(e,t,a,n,l,c);"color"===n&&(d.vertexColorSpace=s.vertexColorSpace),u.attributes.push(d)}c===e.TRIANGULAR_MESH&&(u.index=function(e,t,r){const n=r.num_faces(),s=3*n,o=4*s,i=e._malloc(o);t.GetTrianglesUInt32Array(r,o,i);const a=new Uint32Array(e.HEAPF32.buffer,i,s).slice();return e._free(i),{array:a,itemSize:1}}(e,t,a));return e.destroy(a),u}(t,i,new Int8Array(n),o),a=e.attributes.map((e=>e.array.buffer));e.index&&a.push(e.index.array.buffer),self.postMessage({type:"decode",id:s.id,geometry:e},a)}catch(e){console.error(e),self.postMessage({type:"error",id:s.id,error:e.message})}finally{t.destroy(i)}}))}}}function re(e,r,n={}){const s=new t.Vector3,o=new t.Quaternion,i=new t.Vector3,a=new t.Matrix4,l=new t.Matrix4,c=new t.Matrix4;n.preserveMatrix=void 0===n.preserveMatrix||n.preserveMatrix,n.preservePosition=void 0===n.preservePosition||n.preservePosition,n.preserveHipPosition=void 0!==n.preserveHipPosition&&n.preserveHipPosition,n.useTargetMatrix=void 0!==n.useTargetMatrix&&n.useTargetMatrix,n.hip=void 0!==n.hip?n.hip:"hip",n.names=n.names||{};const u=r.isObject3D?r.skeleton.bones:se(r),h=e.isObject3D?e.skeleton.bones:se(e);let d,f,m,p,g;if(e.isObject3D?e.skeleton.pose():(n.useTargetMatrix=!0,n.preserveMatrix=!1),n.preservePosition){g=[];for(let e=0;e end && e.g > 0.8281 && e.r == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\t// We correct the previous (-0.25, -0.125) offset we applied:\n\t\t\ttexcoord.x += 0.25 * resolution.x;\n\n\t\t\t// The searches are bias by 1, so adjust the coords accordingly:\n\t\t\ttexcoord.x += resolution.x;\n\n\t\t\t// Disambiguate the length added by the last step:\n\t\t\ttexcoord.x += 2.0 * resolution.x; // Undo last step\n\t\t\ttexcoord.x -= resolution.x * SMAASearchLength(searchTex, e, 0.0, 0.5);\n\n\t\t\treturn texcoord.x;\n\t\t}\n\n\t\tfloat SMAASearchXRight( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\tvec2 e = vec2( 0.0, 1.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord += vec2( 2.0, 0.0 ) * resolution;\n\t\t\t\tif ( ! ( texcoord.x < end && e.g > 0.8281 && e.r == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\ttexcoord.x -= 0.25 * resolution.x;\n\t\t\ttexcoord.x -= resolution.x;\n\t\t\ttexcoord.x -= 2.0 * resolution.x;\n\t\t\ttexcoord.x += resolution.x * SMAASearchLength( searchTex, e, 0.5, 0.5 );\n\n\t\t\treturn texcoord.x;\n\t\t}\n\n\t\tfloat SMAASearchYUp( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\tvec2 e = vec2( 1.0, 0.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord += vec2( 0.0, 2.0 ) * resolution; // WebGL port note: Changed sign\n\t\t\t\tif ( ! ( texcoord.y > end && e.r > 0.8281 && e.g == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\ttexcoord.y -= 0.25 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y -= resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y -= 2.0 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y += resolution.y * SMAASearchLength( searchTex, e.gr, 0.0, 0.5 ); // WebGL port note: Changed sign\n\n\t\t\treturn texcoord.y;\n\t\t}\n\n\t\tfloat SMAASearchYDown( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\tvec2 e = vec2( 1.0, 0.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord -= vec2( 0.0, 2.0 ) * resolution; // WebGL port note: Changed sign\n\t\t\t\tif ( ! ( texcoord.y < end && e.r > 0.8281 && e.g == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\ttexcoord.y += 0.25 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y += resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y += 2.0 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y -= resolution.y * SMAASearchLength( searchTex, e.gr, 0.5, 0.5 ); // WebGL port note: Changed sign\n\n\t\t\treturn texcoord.y;\n\t\t}\n\n\t\tvec2 SMAAArea( sampler2D areaTex, vec2 dist, float e1, float e2, float offset ) {\n\t\t\t// Rounding prevents precision errors of bilinear filtering:\n\t\t\tvec2 texcoord = float( SMAA_AREATEX_MAX_DISTANCE ) * round( 4.0 * vec2( e1, e2 ) ) + dist;\n\n\t\t\t// We do a scale and bias for mapping to texel space:\n\t\t\ttexcoord = SMAA_AREATEX_PIXEL_SIZE * texcoord + ( 0.5 * SMAA_AREATEX_PIXEL_SIZE );\n\n\t\t\t// Move to proper place, according to the subpixel offset:\n\t\t\ttexcoord.y += SMAA_AREATEX_SUBTEX_SIZE * offset;\n\n\t\t\treturn texture2D( areaTex, texcoord, 0.0 ).rg;\n\t\t}\n\n\t\tvec4 SMAABlendingWeightCalculationPS( vec2 texcoord, vec2 pixcoord, vec4 offset[ 3 ], sampler2D edgesTex, sampler2D areaTex, sampler2D searchTex, ivec4 subsampleIndices ) {\n\t\t\tvec4 weights = vec4( 0.0, 0.0, 0.0, 0.0 );\n\n\t\t\tvec2 e = texture2D( edgesTex, texcoord ).rg;\n\n\t\t\tif ( e.g > 0.0 ) { // Edge at north\n\t\t\t\tvec2 d;\n\n\t\t\t\t// Find the distance to the left:\n\t\t\t\tvec2 coords;\n\t\t\t\tcoords.x = SMAASearchXLeft( edgesTex, searchTex, offset[ 0 ].xy, offset[ 2 ].x );\n\t\t\t\tcoords.y = offset[ 1 ].y; // offset[1].y = texcoord.y - 0.25 * resolution.y (@CROSSING_OFFSET)\n\t\t\t\td.x = coords.x;\n\n\t\t\t\t// Now fetch the left crossing edges, two at a time using bilinear\n\t\t\t\t// filtering. Sampling at -0.25 (see @CROSSING_OFFSET) enables to\n\t\t\t\t// discern what value each edge has:\n\t\t\t\tfloat e1 = texture2D( edgesTex, coords, 0.0 ).r;\n\n\t\t\t\t// Find the distance to the right:\n\t\t\t\tcoords.x = SMAASearchXRight( edgesTex, searchTex, offset[ 0 ].zw, offset[ 2 ].y );\n\t\t\t\td.y = coords.x;\n\n\t\t\t\t// We want the distances to be in pixel units (doing this here allow to\n\t\t\t\t// better interleave arithmetic and memory accesses):\n\t\t\t\td = d / resolution.x - pixcoord.x;\n\n\t\t\t\t// SMAAArea below needs a sqrt, as the areas texture is compressed\n\t\t\t\t// quadratically:\n\t\t\t\tvec2 sqrt_d = sqrt( abs( d ) );\n\n\t\t\t\t// Fetch the right crossing edges:\n\t\t\t\tcoords.y -= 1.0 * resolution.y; // WebGL port note: Added\n\t\t\t\tfloat e2 = SMAASampleLevelZeroOffset( edgesTex, coords, ivec2( 1, 0 ) ).r;\n\n\t\t\t\t// Ok, we know how this pattern looks like, now it is time for getting\n\t\t\t\t// the actual area:\n\t\t\t\tweights.rg = SMAAArea( areaTex, sqrt_d, e1, e2, float( subsampleIndices.y ) );\n\t\t\t}\n\n\t\t\tif ( e.r > 0.0 ) { // Edge at west\n\t\t\t\tvec2 d;\n\n\t\t\t\t// Find the distance to the top:\n\t\t\t\tvec2 coords;\n\n\t\t\t\tcoords.y = SMAASearchYUp( edgesTex, searchTex, offset[ 1 ].xy, offset[ 2 ].z );\n\t\t\t\tcoords.x = offset[ 0 ].x; // offset[1].x = texcoord.x - 0.25 * resolution.x;\n\t\t\t\td.x = coords.y;\n\n\t\t\t\t// Fetch the top crossing edges:\n\t\t\t\tfloat e1 = texture2D( edgesTex, coords, 0.0 ).g;\n\n\t\t\t\t// Find the distance to the bottom:\n\t\t\t\tcoords.y = SMAASearchYDown( edgesTex, searchTex, offset[ 1 ].zw, offset[ 2 ].w );\n\t\t\t\td.y = coords.y;\n\n\t\t\t\t// We want the distances to be in pixel units:\n\t\t\t\td = d / resolution.y - pixcoord.y;\n\n\t\t\t\t// SMAAArea below needs a sqrt, as the areas texture is compressed\n\t\t\t\t// quadratically:\n\t\t\t\tvec2 sqrt_d = sqrt( abs( d ) );\n\n\t\t\t\t// Fetch the bottom crossing edges:\n\t\t\t\tcoords.y -= 1.0 * resolution.y; // WebGL port note: Added\n\t\t\t\tfloat e2 = SMAASampleLevelZeroOffset( edgesTex, coords, ivec2( 0, 1 ) ).g;\n\n\t\t\t\t// Get the area for this direction:\n\t\t\t\tweights.ba = SMAAArea( areaTex, sqrt_d, e1, e2, float( subsampleIndices.x ) );\n\t\t\t}\n\n\t\t\treturn weights;\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = SMAABlendingWeightCalculationPS( vUv, vPixcoord, vOffset, tDiffuse, tArea, tSearch, ivec4( 0.0 ) );\n\n\t\t}"},ve={name:"SMAABlendShader",uniforms:{tDiffuse:{value:null},tColor:{value:null},resolution:{value:new t.Vector2(1/1024,1/512)}},vertexShader:"\n\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 2 ];\n\n\t\tvoid SMAANeighborhoodBlendingVS( vec2 texcoord ) {\n\t\t\tvOffset[ 0 ] = texcoord.xyxy + resolution.xyxy * vec4( -1.0, 0.0, 0.0, 1.0 ); // WebGL port note: Changed sign in W component\n\t\t\tvOffset[ 1 ] = texcoord.xyxy + resolution.xyxy * vec4( 1.0, 0.0, 0.0, -1.0 ); // WebGL port note: Changed sign in W component\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tSMAANeighborhoodBlendingVS( vUv );\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform sampler2D tColor;\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 2 ];\n\n\t\tvec4 SMAANeighborhoodBlendingPS( vec2 texcoord, vec4 offset[ 2 ], sampler2D colorTex, sampler2D blendTex ) {\n\t\t\t// Fetch the blending weights for current pixel:\n\t\t\tvec4 a;\n\t\t\ta.xz = texture2D( blendTex, texcoord ).xz;\n\t\t\ta.y = texture2D( blendTex, offset[ 1 ].zw ).g;\n\t\t\ta.w = texture2D( blendTex, offset[ 1 ].xy ).a;\n\n\t\t\t// Is there any blending weight with a value greater than 0.0?\n\t\t\tif ( dot(a, vec4( 1.0, 1.0, 1.0, 1.0 )) < 1e-5 ) {\n\t\t\t\treturn texture2D( colorTex, texcoord, 0.0 );\n\t\t\t} else {\n\t\t\t\t// Up to 4 lines can be crossing a pixel (one through each edge). We\n\t\t\t\t// favor blending by choosing the line with the maximum weight for each\n\t\t\t\t// direction:\n\t\t\t\tvec2 offset;\n\t\t\t\toffset.x = a.a > a.b ? a.a : -a.b; // left vs. right\n\t\t\t\toffset.y = a.g > a.r ? -a.g : a.r; // top vs. bottom // WebGL port note: Changed signs\n\n\t\t\t\t// Then we go in the direction that has the maximum weight:\n\t\t\t\tif ( abs( offset.x ) > abs( offset.y )) { // horizontal vs. vertical\n\t\t\t\t\toffset.y = 0.0;\n\t\t\t\t} else {\n\t\t\t\t\toffset.x = 0.0;\n\t\t\t\t}\n\n\t\t\t\t// Fetch the opposite color and lerp by hand:\n\t\t\t\tvec4 C = texture2D( colorTex, texcoord, 0.0 );\n\t\t\t\ttexcoord += sign( offset ) * resolution;\n\t\t\t\tvec4 Cop = texture2D( colorTex, texcoord, 0.0 );\n\t\t\t\tfloat s = abs( offset.x ) > abs( offset.y ) ? abs( offset.x ) : abs( offset.y );\n\n\t\t\t\t// WebGL port note: Added gamma correction\n\t\t\t\tC.xyz = pow(C.xyz, vec3(2.2));\n\t\t\t\tCop.xyz = pow(Cop.xyz, vec3(2.2));\n\t\t\t\tvec4 mixed = mix(C, Cop, s);\n\t\t\t\tmixed.xyz = pow(mixed.xyz, vec3(1.0 / 2.2));\n\n\t\t\t\treturn mixed;\n\t\t\t}\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = SMAANeighborhoodBlendingPS( vUv, vOffset, tColor, tDiffuse );\n\n\t\t}"};const Ae={name:"OutputShader",uniforms:{tDiffuse:{value:null},toneMappingExposure:{value:1}},vertexShader:"\n\t\tprecision highp float;\n\n\t\tuniform mat4 modelViewMatrix;\n\t\tuniform mat4 projectionMatrix;\n\n\t\tattribute vec3 position;\n\t\tattribute vec2 uv;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\t\n\t\tprecision highp float;\n\n\t\tuniform sampler2D tDiffuse;\n\n\t\t#include \n\t\t#include \n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\n\t\t\t// tone mapping\n\n\t\t\t#ifdef LINEAR_TONE_MAPPING\n\n\t\t\t\tgl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( REINHARD_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( CINEON_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = OptimizedCineonToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( ACES_FILMIC_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( AGX_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb );\n\n\t\t\t#endif\n\n\t\t\t// color space\n\n\t\t\t#ifdef SRGB_TRANSFER\n\n\t\t\t\tgl_FragColor = sRGBTransferOETF( gl_FragColor );\n\n\t\t\t#endif\n\n\t\t}"};const Te={name:"LuminosityHighPassShader",shaderID:"luminosityHighPass",uniforms:{tDiffuse:{value:null},luminosityThreshold:{value:1},smoothWidth:{value:1},defaultColor:{value:new t.Color(0)},defaultOpacity:{value:0}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform vec3 defaultColor;\n\t\tuniform float defaultOpacity;\n\t\tuniform float luminosityThreshold;\n\t\tuniform float smoothWidth;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvec4 texel = texture2D( tDiffuse, vUv );\n\n\t\t\tvec3 luma = vec3( 0.299, 0.587, 0.114 );\n\n\t\t\tfloat v = dot( texel.xyz, luma );\n\n\t\t\tvec4 outputColor = vec4( defaultColor.rgb, defaultOpacity );\n\n\t\t\tfloat alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );\n\n\t\t\tgl_FragColor = mix( outputColor, texel, alpha );\n\n\t\t}"};class be extends le{constructor(e,r,n,s){super(),this.strength=void 0!==r?r:1,this.radius=n,this.threshold=s,this.resolution=void 0!==e?new t.Vector2(e.x,e.y):new t.Vector2(256,256),this.clearColor=new t.Color(0,0,0),this.renderTargetsHorizontal=[],this.renderTargetsVertical=[],this.nMips=5;let o=Math.round(this.resolution.x/2),i=Math.round(this.resolution.y/2);this.renderTargetBright=new t.WebGLRenderTarget(o,i,{type:t.HalfFloatType}),this.renderTargetBright.texture.name="UnrealBloomPass.bright",this.renderTargetBright.texture.generateMipmaps=!1;for(let e=0;e\n\t\t\t\tvarying vec2 vUv;\n\t\t\t\tuniform sampler2D colorTexture;\n\t\t\t\tuniform vec2 invSize;\n\t\t\t\tuniform vec2 direction;\n\t\t\t\tuniform float gaussianCoefficients[KERNEL_RADIUS];\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tfloat weightSum = gaussianCoefficients[0];\n\t\t\t\t\tvec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;\n\t\t\t\t\tfor( int i = 1; i < KERNEL_RADIUS; i ++ ) {\n\t\t\t\t\t\tfloat x = float(i);\n\t\t\t\t\t\tfloat w = gaussianCoefficients[i];\n\t\t\t\t\t\tvec2 uvOffset = direction * invSize * x;\n\t\t\t\t\t\tvec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb;\n\t\t\t\t\t\tvec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb;\n\t\t\t\t\t\tdiffuseSum += (sample1 + sample2) * w;\n\t\t\t\t\t\tweightSum += 2.0 * w;\n\t\t\t\t\t}\n\t\t\t\t\tgl_FragColor = vec4(diffuseSum/weightSum, 1.0);\n\t\t\t\t}"})}getCompositeMaterial(e){return new t.ShaderMaterial({defines:{NUM_MIPS:e},uniforms:{blurTexture1:{value:null},blurTexture2:{value:null},blurTexture3:{value:null},blurTexture4:{value:null},blurTexture5:{value:null},bloomStrength:{value:1},bloomFactors:{value:null},bloomTintColors:{value:null},bloomRadius:{value:0}},vertexShader:"varying vec2 vUv;\n\t\t\t\tvoid main() {\n\t\t\t\t\tvUv = uv;\n\t\t\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\t\t\t\t}",fragmentShader:"varying vec2 vUv;\n\t\t\t\tuniform sampler2D blurTexture1;\n\t\t\t\tuniform sampler2D blurTexture2;\n\t\t\t\tuniform sampler2D blurTexture3;\n\t\t\t\tuniform sampler2D blurTexture4;\n\t\t\t\tuniform sampler2D blurTexture5;\n\t\t\t\tuniform float bloomStrength;\n\t\t\t\tuniform float bloomRadius;\n\t\t\t\tuniform float bloomFactors[NUM_MIPS];\n\t\t\t\tuniform vec3 bloomTintColors[NUM_MIPS];\n\n\t\t\t\tfloat lerpBloomFactor(const in float factor) {\n\t\t\t\t\tfloat mirrorFactor = 1.2 - factor;\n\t\t\t\t\treturn mix(factor, mirrorFactor, bloomRadius);\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tgl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );\n\t\t\t\t}"})}}be.BlurDirectionX=new t.Vector2(1,0),be.BlurDirectionY=new t.Vector2(0,1);const we={name:"BrightnessContrastShader",uniforms:{tDiffuse:{value:null},brightness:{value:0},contrast:{value:0}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform float brightness;\n\t\tuniform float contrast;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\n\t\t\tgl_FragColor.rgb += brightness;\n\n\t\t\tif (contrast > 0.0) {\n\t\t\t\tgl_FragColor.rgb = (gl_FragColor.rgb - 0.5) / (1.0 - contrast) + 0.5;\n\t\t\t} else {\n\t\t\t\tgl_FragColor.rgb = (gl_FragColor.rgb - 0.5) * (1.0 + contrast) + 0.5;\n\t\t\t}\n\n\t\t}"},Me={name:"HueSaturationShader",uniforms:{tDiffuse:{value:null},hue:{value:0},saturation:{value:0}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform float hue;\n\t\tuniform float saturation;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\n\t\t\t// hue\n\t\t\tfloat angle = hue * 3.14159265;\n\t\t\tfloat s = sin(angle), c = cos(angle);\n\t\t\tvec3 weights = (vec3(2.0 * c, -sqrt(3.0) * s - c, sqrt(3.0) * s - c) + 1.0) / 3.0;\n\t\t\tfloat len = length(gl_FragColor.rgb);\n\t\t\tgl_FragColor.rgb = vec3(\n\t\t\t\tdot(gl_FragColor.rgb, weights.xyz),\n\t\t\t\tdot(gl_FragColor.rgb, weights.zxy),\n\t\t\t\tdot(gl_FragColor.rgb, weights.yzx)\n\t\t\t);\n\n\t\t\t// saturation\n\t\t\tfloat average = (gl_FragColor.r + gl_FragColor.g + gl_FragColor.b) / 3.0;\n\t\t\tif (saturation > 0.0) {\n\t\t\t\tgl_FragColor.rgb += (average - gl_FragColor.rgb) * (1.0 - 1.0 / (1.001 - saturation));\n\t\t\t} else {\n\t\t\t\tgl_FragColor.rgb += (average - gl_FragColor.rgb) * (-saturation);\n\t\t\t}\n\n\t\t}"},Se={name:"ExposureShader",uniforms:{tDiffuse:{value:null},exposure:{value:1}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform float exposure;\n\n\t\tuniform sampler2D tDiffuse;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\t\t\tgl_FragColor.rgb *= exposure;\n\n\t\t}"};e.BrightnessContrastShader=we,e.DRACOLoader=ee,e.EffectComposer=class{constructor(e,r){if(this.renderer=e,this._pixelRatio=e.getPixelRatio(),void 0===r){const n=e.getSize(new t.Vector2);this._width=n.width,this._height=n.height,(r=new t.WebGLRenderTarget(this._width*this._pixelRatio,this._height*this._pixelRatio,{type:t.HalfFloatType})).texture.name="EffectComposer.rt1"}else this._width=r.width,this._height=r.height;this.renderTarget1=r,this.renderTarget2=r.clone(),this.renderTarget2.texture.name="EffectComposer.rt2",this.writeBuffer=this.renderTarget1,this.readBuffer=this.renderTarget2,this.renderToScreen=!0,this.passes=[],this.copyPass=new fe(ae),this.copyPass.material.blending=t.NoBlending,this.clock=new t.Clock}swapBuffers(){const e=this.readBuffer;this.readBuffer=this.writeBuffer,this.writeBuffer=e}addPass(e){this.passes.push(e),e.setSize(this._width*this._pixelRatio,this._height*this._pixelRatio)}insertPass(e,t){this.passes.splice(t,0,e),e.setSize(this._width*this._pixelRatio,this._height*this._pixelRatio)}removePass(e){const t=this.passes.indexOf(e);-1!==t&&this.passes.splice(t,1)}isLastEnabledPass(e){for(let t=e+1;t=2.0 are supported.")));const h=new _(s,{path:t||this.resourcePath||"",crossOrigin:this.crossOrigin,requestHeader:this.requestHeader,manager:this.manager,ktx2Loader:this.ktx2Loader,meshoptDecoder:this.meshoptDecoder});h.fileLoader.setRequestHeader(this.requestHeader);for(let e=0;e=0&&void 0===l[t]&&console.warn('THREE.GLTFLoader: Unknown extension "'+t+'".')}}h.setExtensions(o),h.setPlugins(l),h.parse(r,n)}parseAsync(e,t){const r=this;return new Promise((function(n,s){r.parse(e,t,n,s)}))}}function s(){let e={};return{get:function(t){return e[t]},add:function(t,r){e[t]=r},remove:function(t){delete e[t]},removeAll:function(){e={}}}}const i={KHR_BINARY_GLTF:"KHR_binary_glTF",KHR_DRACO_MESH_COMPRESSION:"KHR_draco_mesh_compression",KHR_LIGHTS_PUNCTUAL:"KHR_lights_punctual",KHR_MATERIALS_CLEARCOAT:"KHR_materials_clearcoat",KHR_MATERIALS_IOR:"KHR_materials_ior",KHR_MATERIALS_SHEEN:"KHR_materials_sheen",KHR_MATERIALS_SPECULAR:"KHR_materials_specular",KHR_MATERIALS_TRANSMISSION:"KHR_materials_transmission",KHR_MATERIALS_IRIDESCENCE:"KHR_materials_iridescence",KHR_MATERIALS_ANISOTROPY:"KHR_materials_anisotropy",KHR_MATERIALS_UNLIT:"KHR_materials_unlit",KHR_MATERIALS_VOLUME:"KHR_materials_volume",KHR_TEXTURE_BASISU:"KHR_texture_basisu",KHR_TEXTURE_TRANSFORM:"KHR_texture_transform",KHR_MESH_QUANTIZATION:"KHR_mesh_quantization",KHR_MATERIALS_EMISSIVE_STRENGTH:"KHR_materials_emissive_strength",EXT_MATERIALS_BUMP:"EXT_materials_bump",EXT_TEXTURE_WEBP:"EXT_texture_webp",EXT_TEXTURE_AVIF:"EXT_texture_avif",EXT_MESHOPT_COMPRESSION:"EXT_meshopt_compression",EXT_MESH_GPU_INSTANCING:"EXT_mesh_gpu_instancing"};class o{constructor(e){this.parser=e,this.name=i.KHR_LIGHTS_PUNCTUAL,this.cache={refs:{},uses:{}}}_markDefs(){const e=this.parser,t=this.parser.json.nodes||[];for(let r=0,n=t.length;r=0)throw new Error("THREE.GLTFLoader: setKTX2Loader must be called before loading KTX2 textures");return null}return t.loadTextureImage(e,s.source,i)}}class T{constructor(e){this.parser=e,this.name=i.EXT_TEXTURE_WEBP,this.isSupported=null}loadTexture(e){const t=this.name,r=this.parser,n=r.json,s=n.textures[e];if(!s.extensions||!s.extensions[t])return null;const i=s.extensions[t],o=n.images[i.source];let a=r.textureLoader;if(o.uri){const e=r.options.manager.getHandler(o.uri);null!==e&&(a=e)}return this.detectSupport().then((function(s){if(s)return r.loadTextureImage(e,i.source,a);if(n.extensionsRequired&&n.extensionsRequired.indexOf(t)>=0)throw new Error("THREE.GLTFLoader: WebP required by asset but unsupported.");return r.loadTexture(e)}))}detectSupport(){return this.isSupported||(this.isSupported=new Promise((function(e){const t=new Image;t.src="data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA",t.onload=t.onerror=function(){e(1===t.height)}}))),this.isSupported}}class M{constructor(e){this.parser=e,this.name=i.EXT_TEXTURE_AVIF,this.isSupported=null}loadTexture(e){const t=this.name,r=this.parser,n=r.json,s=n.textures[e];if(!s.extensions||!s.extensions[t])return null;const i=s.extensions[t],o=n.images[i.source];let a=r.textureLoader;if(o.uri){const e=r.options.manager.getHandler(o.uri);null!==e&&(a=e)}return this.detectSupport().then((function(s){if(s)return r.loadTextureImage(e,i.source,a);if(n.extensionsRequired&&n.extensionsRequired.indexOf(t)>=0)throw new Error("THREE.GLTFLoader: AVIF required by asset but unsupported.");return r.loadTexture(e)}))}detectSupport(){return this.isSupported||(this.isSupported=new Promise((function(e){const t=new Image;t.src="data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEDQgMgkQAAAAB8dSLfI=",t.onload=t.onerror=function(){e(1===t.height)}}))),this.isSupported}}class A{constructor(e){this.name=i.EXT_MESHOPT_COMPRESSION,this.parser=e}loadBufferView(e){const t=this.parser.json,r=t.bufferViews[e];if(r.extensions&&r.extensions[this.name]){const e=r.extensions[this.name],n=this.parser.getDependency("buffer",e.buffer),s=this.parser.options.meshoptDecoder;if(!s||!s.supported){if(t.extensionsRequired&&t.extensionsRequired.indexOf(this.name)>=0)throw new Error("THREE.GLTFLoader: setMeshoptDecoder must be called before loading compressed files");return null}return n.then((function(t){const r=e.byteOffset||0,n=e.byteLength||0,i=e.count,o=e.byteStride,a=new Uint8Array(t,r,n);return s.decodeGltfBufferAsync?s.decodeGltfBufferAsync(i,o,a,e.mode,e.filter).then((function(e){return e.buffer})):s.ready.then((function(){const t=new ArrayBuffer(i*o);return s.decodeGltfBuffer(new Uint8Array(t),i,o,a,e.mode,e.filter),t}))}))}return null}}class w{constructor(e){this.name=i.EXT_MESH_GPU_INSTANCING,this.parser=e}createNodeMesh(e){const r=this.parser.json,n=r.nodes[e];if(!n.extensions||!n.extensions[this.name]||void 0===n.mesh)return null;const s=r.meshes[n.mesh];for(const e of s.primitives)if(e.mode!==B.TRIANGLES&&e.mode!==B.TRIANGLE_STRIP&&e.mode!==B.TRIANGLE_FAN&&void 0!==e.mode)return null;const i=n.extensions[this.name].attributes,o=[],a={};for(const e in i)o.push(this.parser.getDependency("accessor",i[e]).then((t=>(a[e]=t,a[e]))));return o.length<1?null:(o.push(this.parser.createNodeMesh(e)),Promise.all(o).then((e=>{const r=e.pop(),n=r.isGroup?r.children:[r],s=e[0].count,i=[];for(const e of n){const r=new t.Matrix4,n=new t.Vector3,o=new t.Quaternion,l=new t.Vector3(1,1,1),c=new t.InstancedMesh(e.geometry,e.material,s);for(let e=0;e-1,o=i?navigator.userAgent.match(/Firefox\/([0-9]+)\./)[1]:-1),"undefined"==typeof createImageBitmap||n||i&&o<98?this.textureLoader=new t.TextureLoader(this.options.manager):this.textureLoader=new t.ImageBitmapLoader(this.options.manager),this.textureLoader.setCrossOrigin(this.options.crossOrigin),this.textureLoader.setRequestHeader(this.options.requestHeader),this.fileLoader=new t.FileLoader(this.options.manager),this.fileLoader.setResponseType("arraybuffer"),"use-credentials"===this.options.crossOrigin&&this.fileLoader.setWithCredentials(!0)}setExtensions(e){this.extensions=e}setPlugins(e){this.plugins=e}parse(e,t){const r=this,n=this.json,s=this.extensions;this.cache.removeAll(),this.nodeCache={},this._invokeAll((function(e){return e._markDefs&&e._markDefs()})),Promise.all(this._invokeAll((function(e){return e.beforeRoot&&e.beforeRoot()}))).then((function(){return Promise.all([r.getDependencies("scene"),r.getDependencies("animation"),r.getDependencies("camera")])})).then((function(t){const i={scene:t[0][n.scene||0],scenes:t[0],animations:t[1],cameras:t[2],asset:n.asset,parser:r,userData:{}};return V(s,i,n),X(i,n),Promise.all(r._invokeAll((function(e){return e.afterRoot&&e.afterRoot(i)}))).then((function(){e(i)}))})).catch(t)}_markDefs(){const e=this.json.nodes||[],t=this.json.skins||[],r=this.json.meshes||[];for(let r=0,n=t.length;r{const r=this.associations.get(e);null!=r&&this.associations.set(t,r);for(const[r,n]of e.children.entries())s(n,t.children[r])};return s(r,n),n.name+="_instance_"+e.uses[t]++,n}_invokeOne(e){const t=Object.values(this.plugins);t.push(this);for(let r=0;r=2&&f.setY(t,u[e*o+1]),o>=3&&f.setZ(t,u[e*o+2]),o>=4&&f.setW(t,u[e*o+3]),o>=5)throw new Error("THREE.GLTFLoader: Unsupported itemSize in sparse BufferAttribute.")}}return f}))}loadTexture(e){const t=this.json,r=this.options,n=t.textures[e].source,s=t.images[n];let i=this.textureLoader;if(s.uri){const e=r.manager.getHandler(s.uri);null!==e&&(i=e)}return this.loadTextureImage(e,n,i)}loadTextureImage(e,r,n){const s=this,i=this.json,o=i.textures[e],a=i.images[r],l=(a.uri||a.bufferView)+":"+o.sampler;if(this.textureCache[l])return this.textureCache[l];const c=this.loadImageSource(r,n).then((function(r){r.flipY=!1,r.name=o.name||a.name||"",""===r.name&&"string"==typeof a.uri&&!1===a.uri.startsWith("data:image/")&&(r.name=a.uri);const n=(i.samplers||{})[o.sampler]||{};return r.magFilter=I[n.magFilter]||t.LinearFilter,r.minFilter=I[n.minFilter]||t.LinearMipmapLinearFilter,r.wrapS=F[n.wrapS]||t.RepeatWrapping,r.wrapT=F[n.wrapT]||t.RepeatWrapping,s.associations.set(r,{textures:e}),r})).catch((function(){return null}));return this.textureCache[l]=c,c}loadImageSource(e,r){const n=this,s=this.json,i=this.options;if(void 0!==this.sourceCache[e])return this.sourceCache[e].then((e=>e.clone()));const o=s.images[e],a=self.URL||self.webkitURL;let l=o.uri||"",c=!1;if(void 0!==o.bufferView)l=n.getDependency("bufferView",o.bufferView).then((function(e){c=!0;const t=new Blob([e],{type:o.mimeType});return l=a.createObjectURL(t),l}));else if(void 0===o.uri)throw new Error("THREE.GLTFLoader: Image "+e+" is missing URI and bufferView");const h=Promise.resolve(l).then((function(e){return new Promise((function(n,s){let o=n;!0===r.isImageBitmapLoader&&(o=function(e){const r=new t.Texture(e);r.needsUpdate=!0,n(r)}),r.load(t.LoaderUtils.resolveURL(e,i.path),o,void 0,s)}))})).then((function(e){var t;return!0===c&&a.revokeObjectURL(l),e.userData.mimeType=o.mimeType||((t=o.uri).search(/\.jpe?g($|\?)/i)>0||0===t.search(/^data\:image\/jpeg/)?"image/jpeg":t.search(/\.webp($|\?)/i)>0||0===t.search(/^data\:image\/webp/)?"image/webp":"image/png"),e})).catch((function(e){throw console.error("THREE.GLTFLoader: Couldn't load texture",l),e}));return this.sourceCache[e]=h,h}assignTexture(e,t,r,n){const s=this;return this.getDependency("texture",r.index).then((function(o){if(!o)return null;if(void 0!==r.texCoord&&r.texCoord>0&&((o=o.clone()).channel=r.texCoord),s.extensions[i.KHR_TEXTURE_TRANSFORM]){const e=void 0!==r.extensions?r.extensions[i.KHR_TEXTURE_TRANSFORM]:void 0;if(e){const t=s.associations.get(o);o=s.extensions[i.KHR_TEXTURE_TRANSFORM].extendTexture(o,e),s.associations.set(o,t)}}return void 0!==n&&(o.colorSpace=n),e[t]=o,o}))}assignFinalMaterial(e){const r=e.geometry;let n=e.material;const s=void 0===r.attributes.tangent,i=void 0!==r.attributes.color,o=void 0===r.attributes.normal;if(e.isPoints){const e="PointsMaterial:"+n.uuid;let r=this.cache.get(e);r||(r=new t.PointsMaterial,t.Material.prototype.copy.call(r,n),r.color.copy(n.color),r.map=n.map,r.sizeAttenuation=!1,this.cache.add(e,r)),n=r}else if(e.isLine){const e="LineBasicMaterial:"+n.uuid;let r=this.cache.get(e);r||(r=new t.LineBasicMaterial,t.Material.prototype.copy.call(r,n),r.color.copy(n.color),r.map=n.map,this.cache.add(e,r)),n=r}if(s||i||o){let e="ClonedMaterial:"+n.uuid+":";s&&(e+="derivative-tangents:"),i&&(e+="vertex-colors:"),o&&(e+="flat-shading:");let t=this.cache.get(e);t||(t=n.clone(),i&&(t.vertexColors=!0),o&&(t.flatShading=!0),s&&(t.normalScale&&(t.normalScale.y*=-1),t.clearcoatNormalScale&&(t.clearcoatNormalScale.y*=-1)),this.cache.add(e,t),this.associations.set(t,this.associations.get(n))),n=t}e.material=n}getMaterialType(){return t.MeshStandardMaterial}loadMaterial(e){const r=this,n=this.json,s=this.extensions,o=n.materials[e];let a;const l={},c=[];if((o.extensions||{})[i.KHR_MATERIALS_UNLIT]){const e=s[i.KHR_MATERIALS_UNLIT];a=e.getMaterialType(),c.push(e.extendParams(l,o,r))}else{const n=o.pbrMetallicRoughness||{};if(l.color=new t.Color(1,1,1),l.opacity=1,Array.isArray(n.baseColorFactor)){const e=n.baseColorFactor;l.color.setRGB(e[0],e[1],e[2],t.LinearSRGBColorSpace),l.opacity=e[3]}void 0!==n.baseColorTexture&&c.push(r.assignTexture(l,"map",n.baseColorTexture,t.SRGBColorSpace)),l.metalness=void 0!==n.metallicFactor?n.metallicFactor:1,l.roughness=void 0!==n.roughnessFactor?n.roughnessFactor:1,void 0!==n.metallicRoughnessTexture&&(c.push(r.assignTexture(l,"metalnessMap",n.metallicRoughnessTexture)),c.push(r.assignTexture(l,"roughnessMap",n.metallicRoughnessTexture))),a=this._invokeOne((function(t){return t.getMaterialType&&t.getMaterialType(e)})),c.push(Promise.all(this._invokeAll((function(t){return t.extendMaterialParams&&t.extendMaterialParams(e,l)}))))}!0===o.doubleSided&&(l.side=t.DoubleSide);const h=o.alphaMode||z;if(h===W?(l.transparent=!0,l.depthWrite=!1):(l.transparent=!1,h===j&&(l.alphaTest=void 0!==o.alphaCutoff?o.alphaCutoff:.5)),void 0!==o.normalTexture&&a!==t.MeshBasicMaterial&&(c.push(r.assignTexture(l,"normalMap",o.normalTexture)),l.normalScale=new t.Vector2(1,1),void 0!==o.normalTexture.scale)){const e=o.normalTexture.scale;l.normalScale.set(e,e)}if(void 0!==o.occlusionTexture&&a!==t.MeshBasicMaterial&&(c.push(r.assignTexture(l,"aoMap",o.occlusionTexture)),void 0!==o.occlusionTexture.strength&&(l.aoMapIntensity=o.occlusionTexture.strength)),void 0!==o.emissiveFactor&&a!==t.MeshBasicMaterial){const e=o.emissiveFactor;l.emissive=(new t.Color).setRGB(e[0],e[1],e[2],t.LinearSRGBColorSpace)}return void 0!==o.emissiveTexture&&a!==t.MeshBasicMaterial&&c.push(r.assignTexture(l,"emissiveMap",o.emissiveTexture,t.SRGBColorSpace)),Promise.all(c).then((function(){const t=new a(l);return o.name&&(t.name=o.name),X(t,o),r.associations.set(t,{materials:e}),o.extensions&&V(s,t,o),t}))}createUniqueName(e){const r=t.PropertyBinding.sanitizeNodeName(e||"");return r in this.nodeNamesUsed?r+"_"+ ++this.nodeNamesUsed[r]:(this.nodeNamesUsed[r]=0,r)}loadGeometries(e){const r=this,n=this.extensions,s=this.primitiveCache;function o(e){return n[i.KHR_DRACO_MESH_COMPRESSION].decodePrimitive(e,r).then((function(t){return J(t,e,r)}))}const a=[];for(let n=0,l=e.length;n0&&Y(p,o),p.name=n.createUniqueName(o.name||"mesh_"+e),X(p,o),d.extensions&&V(i,p,d),n.assignFinalMaterial(p),h.push(p)}for(let t=0,r=h.length;t1?new t.Group:1===r.length?r[0]:new t.Object3D,a!==r[0])for(let e=0,t=r.length;e{const r=new Map;for(const[e,n]of s.associations)(e instanceof t.Material||e instanceof t.Texture)&&r.set(e,n);return e.traverse((e=>{const t=s.associations.get(e);null!=t&&r.set(e,t)})),r})(i),i}))}_createAnimationTracks(e,r,n,s,i){const o=[],a=e.name?e.name:e.uuid,l=[];let c;switch(k[i.path]===k.weights?e.traverse((function(e){e.morphTargetInfluences&&l.push(e.name?e.name:e.uuid)})):l.push(a),k[i.path]){case k.weights:c=t.NumberKeyframeTrack;break;case k.rotation:c=t.QuaternionKeyframeTrack;break;case k.position:case k.scale:c=t.VectorKeyframeTrack;break;default:if(1===n.itemSize)c=t.NumberKeyframeTrack;else c=t.VectorKeyframeTrack}const h=void 0!==s.interpolation?G[s.interpolation]:t.InterpolateLinear,u=this._getArrayFromAccessor(n);for(let e=0,t=l.length;e{this.parse(e,r,s)}),n,s)}parse(e,r,n=(()=>{})){this.decodeDracoFile(e,r,null,null,t.SRGBColorSpace).catch(n)}decodeDracoFile(e,r,n,s,i=t.LinearSRGBColorSpace,o=(()=>{})){const a={attributeIDs:n||this.defaultAttributeIDs,attributeTypes:s||this.defaultAttributeTypes,useUniqueIDs:!!n,vertexColorSpace:i};return this.decodeGeometry(e,a).then(r).catch(o)}decodeGeometry(e,t){const r=JSON.stringify(t);if($.has(e)){const t=$.get(e);if(t.key===r)return t.promise;if(0===e.byteLength)throw new Error("THREE.DRACOLoader: Unable to re-decode a buffer with different settings. Buffer has already been transferred.")}let n;const s=this.workerNextTaskID++,i=e.byteLength,o=this._getWorker(s,i).then((r=>(n=r,new Promise(((r,i)=>{n._callbacks[s]={resolve:r,reject:i},n.postMessage({type:"decode",id:s,taskConfig:t,buffer:e},[e])}))))).then((e=>this._createGeometry(e.geometry)));return o.catch((()=>!0)).then((()=>{n&&s&&this._releaseTask(n,s)})),$.set(e,{key:r,promise:o}),o}_createGeometry(e){const r=new t.BufferGeometry;e.index&&r.setIndex(new t.BufferAttribute(e.index.array,1));for(let n=0;n{n.load(e,t,void 0,r)}))}preload(){return this._initDecoder(),this}_initDecoder(){if(this.decoderPending)return this.decoderPending;const e="object"!=typeof WebAssembly||"js"===this.decoderConfig.type,t=[];return e?t.push(this._loadLibrary("draco_decoder.js","text")):(t.push(this._loadLibrary("draco_wasm_wrapper.js","text")),t.push(this._loadLibrary("draco_decoder.wasm","arraybuffer"))),this.decoderPending=Promise.all(t).then((t=>{const r=t[0];e||(this.decoderConfig.wasmBinary=t[1]);const n=te.toString(),s=["/* draco decoder */",r,"","/* worker */",n.substring(n.indexOf("{")+1,n.lastIndexOf("}"))].join("\n");this.workerSourceURL=URL.createObjectURL(new Blob([s]))})),this.decoderPending}_getWorker(e,t){return this._initDecoder().then((()=>{if(this.workerPool.lengtht._taskLoad?-1:1}));const r=this.workerPool[this.workerPool.length-1];return r._taskCosts[e]=t,r._taskLoad+=t,r}))}_releaseTask(e,t){e._taskLoad-=e._taskCosts[t],delete e._callbacks[t],delete e._taskCosts[t]}debug(){console.log("Task load: ",this.workerPool.map((e=>e._taskLoad)))}dispose(){for(let e=0;e{const t=e.draco,o=new t.Decoder;try{const e=function(e,t,n,s){const i=s.attributeIDs,o=s.attributeTypes;let a,l;const c=t.GetEncodedGeometryType(n);if(c===e.TRIANGULAR_MESH)a=new e.Mesh,l=t.DecodeArrayToMesh(n,n.byteLength,a);else{if(c!==e.POINT_CLOUD)throw new Error("THREE.DRACOLoader: Unexpected geometry type.");a=new e.PointCloud,l=t.DecodeArrayToPointCloud(n,n.byteLength,a)}if(!l.ok()||0===a.ptr)throw new Error("THREE.DRACOLoader: Decoding failed: "+l.error_msg());const h={index:null,attributes:[]};for(const n in i){const l=self[o[n]];let c,u;if(s.useUniqueIDs)u=i[n],c=t.GetAttributeByUniqueId(a,u);else{if(u=t.GetAttributeId(a,e[i[n]]),-1===u)continue;c=t.GetAttribute(a,u)}const d=r(e,t,a,n,l,c);"color"===n&&(d.vertexColorSpace=s.vertexColorSpace),h.attributes.push(d)}c===e.TRIANGULAR_MESH&&(h.index=function(e,t,r){const n=r.num_faces(),s=3*n,i=4*s,o=e._malloc(i);t.GetTrianglesUInt32Array(r,i,o);const a=new Uint32Array(e.HEAPF32.buffer,o,s).slice();return e._free(o),{array:a,itemSize:1}}(e,t,a));return e.destroy(a),h}(t,o,new Int8Array(n),i),a=e.attributes.map((e=>e.array.buffer));e.index&&a.push(e.index.array.buffer),self.postMessage({type:"decode",id:s.id,geometry:e},a)}catch(e){console.error(e),self.postMessage({type:"error",id:s.id,error:e.message})}finally{t.destroy(o)}}))}}}function re(e,r,n={}){const s=new t.Vector3,i=new t.Quaternion,o=new t.Vector3,a=new t.Matrix4,l=new t.Matrix4,c=new t.Matrix4;n.preserveMatrix=void 0===n.preserveMatrix||n.preserveMatrix,n.preservePosition=void 0===n.preservePosition||n.preservePosition,n.preserveHipPosition=void 0!==n.preserveHipPosition&&n.preserveHipPosition,n.useTargetMatrix=void 0!==n.useTargetMatrix&&n.useTargetMatrix,n.hip=void 0!==n.hip?n.hip:"hip",n.names=n.names||{};const h=r.isObject3D?r.skeleton.bones:se(r),u=e.isObject3D?e.skeleton.bones:se(e);let d,p,f,m,g;if(e.isObject3D?e.skeleton.pose():(n.useTargetMatrix=!0,n.preserveMatrix=!1),n.preservePosition){g=[];for(let e=0;ee&&(n.scale.set(1e-10,1e-10,1e-10),n.visible=!1),"Y"===n.name&&Math.abs(ye.copy(Oe).applyQuaternion(t).dot(this.eye))>e&&(n.scale.set(1e-10,1e-10,1e-10),n.visible=!1),"Z"===n.name&&Math.abs(ye.copy(Be).applyQuaternion(t).dot(this.eye))>e&&(n.scale.set(1e-10,1e-10,1e-10),n.visible=!1),"XY"===n.name&&Math.abs(ye.copy(Be).applyQuaternion(t).dot(this.eye)).9&&(n.visible=!1)),"Y"===this.axis&&(he.setFromEuler(be.set(0,0,Math.PI/2)),n.quaternion.copy(t).multiply(he),Math.abs(ye.copy(Oe).applyQuaternion(t).dot(this.eye))>.9&&(n.visible=!1)),"Z"===this.axis&&(he.setFromEuler(be.set(0,Math.PI/2,0)),n.quaternion.copy(t).multiply(he),Math.abs(ye.copy(Be).applyQuaternion(t).dot(this.eye))>.9&&(n.visible=!1)),"XYZE"===this.axis&&(he.setFromEuler(be.set(0,Math.PI/2,0)),ye.copy(this.rotationAxis),n.quaternion.setFromRotationMatrix(Ce.lookAt(Se,ye,Oe)),n.quaternion.multiply(he),n.visible=this.dragging),"E"===this.axis&&(n.visible=!1)):"START"===n.name?(n.position.copy(this.worldPositionStart),n.visible=this.dragging):"END"===n.name?(n.position.copy(this.worldPosition),n.visible=this.dragging):"DELTA"===n.name?(n.position.copy(this.worldPositionStart),n.quaternion.copy(this.worldQuaternionStart),le.set(1e-10,1e-10,1e-10).add(this.worldPositionStart).sub(this.worldPosition).multiplyScalar(-1),le.applyQuaternion(this.worldQuaternionStart.clone().invert()),n.scale.copy(le),n.visible=this.dragging):(n.quaternion.copy(t),this.dragging?n.position.copy(this.worldPositionStart):n.position.copy(this.worldPosition),this.axis&&(n.visible=-1!==this.axis.search(n.name)))}super.updateMatrixWorld(e)}}class He extends t.Mesh{constructor(){super(new t.PlaneGeometry(1e5,1e5,2,2),new t.MeshBasicMaterial({visible:!1,wireframe:!0,side:t.DoubleSide,transparent:!0,opacity:.1,toneMapped:!1})),this.isTransformControlsPlane=!0,this.type="TransformControlsPlane"}updateMatrixWorld(e){let t=this.space;switch(this.position.copy(this.worldPosition),"scale"===this.mode&&(t="local"),Ne.copy(De).applyQuaternion("local"===t?this.worldQuaternion:Pe),Ie.copy(Oe).applyQuaternion("local"===t?this.worldQuaternion:Pe),Fe.copy(Be).applyQuaternion("local"===t?this.worldQuaternion:Pe),ye.copy(Ie),this.mode){case"translate":case"scale":switch(this.axis){case"X":ye.copy(this.eye).cross(Ne),Le.copy(Ne).cross(ye);break;case"Y":ye.copy(this.eye).cross(Ie),Le.copy(Ie).cross(ye);break;case"Z":ye.copy(this.eye).cross(Fe),Le.copy(Fe).cross(ye);break;case"XY":Le.copy(Fe);break;case"YZ":Le.copy(Ne);break;case"XZ":ye.copy(Fe),Le.copy(Ie);break;case"XYZ":case"E":Le.set(0,0,0)}break;default:Le.set(0,0,0)}0===Le.length()?this.quaternion.copy(this.cameraQuaternion):(Re.lookAt(le.set(0,0,0),Le,ye),this.quaternion.setFromRotationMatrix(Re)),super.updateMatrixWorld(e)}}const ke=new t.Frustum,Ge=new t.Vector3,ze=new t.Vector3,je=new t.Vector3,We=new t.Vector3,Ve=new t.Vector3,Xe=new t.Vector3,Ye=new t.Vector3,Qe=new t.Vector3,qe=new t.Vector3,Ke=new t.Vector3,Ze=new t.Vector3,_e=new t.Vector3,Je=new t.Vector3,$e=new t.Vector3,et=new t.Matrix4,tt=new t.Quaternion,rt=new t.Vector3;const nt={name:"CopyShader",uniforms:{tDiffuse:{value:null},opacity:{value:1}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform float opacity;\n\n\t\tuniform sampler2D tDiffuse;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvec4 texel = texture2D( tDiffuse, vUv );\n\t\t\tgl_FragColor = opacity * texel;\n\n\n\t\t}"};class st{constructor(){this.isPass=!0,this.enabled=!0,this.needsSwap=!0,this.clear=!1,this.renderToScreen=!1}setSize(){}render(){console.error("THREE.Pass: .render() must be implemented in derived pass.")}dispose(){}}const it=new t.OrthographicCamera(-1,1,1,-1,0,1);class ot extends t.BufferGeometry{constructor(){super(),this.setAttribute("position",new t.Float32BufferAttribute([-1,3,0,-1,-1,0,3,-1,0],3)),this.setAttribute("uv",new t.Float32BufferAttribute([0,2,0,0,2,0],2))}}const at=new ot;class lt{constructor(e){this._mesh=new t.Mesh(at,e)}dispose(){this._mesh.geometry.dispose()}render(e){e.render(this._mesh,it)}get material(){return this._mesh.material}set material(e){this._mesh.material=e}}class ct extends st{constructor(e,r){super(),this.textureID=void 0!==r?r:"tDiffuse",e instanceof t.ShaderMaterial?(this.uniforms=e.uniforms,this.material=e):e&&(this.uniforms=t.UniformsUtils.clone(e.uniforms),this.material=new t.ShaderMaterial({name:void 0!==e.name?e.name:"unspecified",defines:Object.assign({},e.defines),uniforms:this.uniforms,vertexShader:e.vertexShader,fragmentShader:e.fragmentShader})),this.fsQuad=new lt(this.material)}render(e,t,r){this.uniforms[this.textureID]&&(this.uniforms[this.textureID].value=r.texture),this.fsQuad.material=this.material,this.renderToScreen?(e.setRenderTarget(null),this.fsQuad.render(e)):(e.setRenderTarget(t),this.clear&&e.clear(e.autoClearColor,e.autoClearDepth,e.autoClearStencil),this.fsQuad.render(e))}dispose(){this.material.dispose(),this.fsQuad.dispose()}}class ht extends st{constructor(e,t){super(),this.scene=e,this.camera=t,this.clear=!0,this.needsSwap=!1,this.inverse=!1}render(e,t,r){const n=e.getContext(),s=e.state;let i,o;s.buffers.color.setMask(!1),s.buffers.depth.setMask(!1),s.buffers.color.setLocked(!0),s.buffers.depth.setLocked(!0),this.inverse?(i=0,o=1):(i=1,o=0),s.buffers.stencil.setTest(!0),s.buffers.stencil.setOp(n.REPLACE,n.REPLACE,n.REPLACE),s.buffers.stencil.setFunc(n.ALWAYS,i,4294967295),s.buffers.stencil.setClear(o),s.buffers.stencil.setLocked(!0),e.setRenderTarget(r),this.clear&&e.clear(),e.render(this.scene,this.camera),e.setRenderTarget(t),this.clear&&e.clear(),e.render(this.scene,this.camera),s.buffers.color.setLocked(!1),s.buffers.depth.setLocked(!1),s.buffers.color.setMask(!0),s.buffers.depth.setMask(!0),s.buffers.stencil.setLocked(!1),s.buffers.stencil.setFunc(n.EQUAL,1,4294967295),s.buffers.stencil.setOp(n.KEEP,n.KEEP,n.KEEP),s.buffers.stencil.setLocked(!0)}}class ut extends st{constructor(){super(),this.needsSwap=!1}render(e){e.state.buffers.stencil.setLocked(!1),e.state.buffers.stencil.setTest(!1)}}class dt extends st{constructor(e,r,n,s){super(),this.renderScene=r,this.renderCamera=n,this.selectedObjects=void 0!==s?s:[],this.visibleEdgeColor=new t.Color(1,1,1),this.hiddenEdgeColor=new t.Color(.1,.04,.02),this.edgeGlow=0,this.usePatternTexture=!1,this.edgeThickness=1,this.edgeStrength=3,this.downSampleRatio=2,this.pulsePeriod=0,this._visibilityCache=new Map,this.resolution=void 0!==e?new t.Vector2(e.x,e.y):new t.Vector2(256,256);const i=Math.round(this.resolution.x/this.downSampleRatio),o=Math.round(this.resolution.y/this.downSampleRatio);this.renderTargetMaskBuffer=new t.WebGLRenderTarget(this.resolution.x,this.resolution.y),this.renderTargetMaskBuffer.texture.name="OutlinePass.mask",this.renderTargetMaskBuffer.texture.generateMipmaps=!1,this.depthMaterial=new t.MeshDepthMaterial,this.depthMaterial.side=t.DoubleSide,this.depthMaterial.depthPacking=t.RGBADepthPacking,this.depthMaterial.blending=t.NoBlending,this.prepareMaskMaterial=this.getPrepareMaskMaterial(),this.prepareMaskMaterial.side=t.DoubleSide,this.prepareMaskMaterial.fragmentShader=function(e,t){const r=t.isPerspectiveCamera?"perspective":"orthographic";return e.replace(/DEPTH_TO_VIEW_Z/g,r+"DepthToViewZ")}(this.prepareMaskMaterial.fragmentShader,this.renderCamera),this.renderTargetDepthBuffer=new t.WebGLRenderTarget(this.resolution.x,this.resolution.y,{type:t.HalfFloatType}),this.renderTargetDepthBuffer.texture.name="OutlinePass.depth",this.renderTargetDepthBuffer.texture.generateMipmaps=!1,this.renderTargetMaskDownSampleBuffer=new t.WebGLRenderTarget(i,o,{type:t.HalfFloatType}),this.renderTargetMaskDownSampleBuffer.texture.name="OutlinePass.depthDownSample",this.renderTargetMaskDownSampleBuffer.texture.generateMipmaps=!1,this.renderTargetBlurBuffer1=new t.WebGLRenderTarget(i,o,{type:t.HalfFloatType}),this.renderTargetBlurBuffer1.texture.name="OutlinePass.blur1",this.renderTargetBlurBuffer1.texture.generateMipmaps=!1,this.renderTargetBlurBuffer2=new t.WebGLRenderTarget(Math.round(i/2),Math.round(o/2),{type:t.HalfFloatType}),this.renderTargetBlurBuffer2.texture.name="OutlinePass.blur2",this.renderTargetBlurBuffer2.texture.generateMipmaps=!1,this.edgeDetectionMaterial=this.getEdgeDetectionMaterial(),this.renderTargetEdgeBuffer1=new t.WebGLRenderTarget(i,o,{type:t.HalfFloatType}),this.renderTargetEdgeBuffer1.texture.name="OutlinePass.edge1",this.renderTargetEdgeBuffer1.texture.generateMipmaps=!1,this.renderTargetEdgeBuffer2=new t.WebGLRenderTarget(Math.round(i/2),Math.round(o/2),{type:t.HalfFloatType}),this.renderTargetEdgeBuffer2.texture.name="OutlinePass.edge2",this.renderTargetEdgeBuffer2.texture.generateMipmaps=!1;this.separableBlurMaterial1=this.getSeperableBlurMaterial(4),this.separableBlurMaterial1.uniforms.texSize.value.set(i,o),this.separableBlurMaterial1.uniforms.kernelRadius.value=1,this.separableBlurMaterial2=this.getSeperableBlurMaterial(4),this.separableBlurMaterial2.uniforms.texSize.value.set(Math.round(i/2),Math.round(o/2)),this.separableBlurMaterial2.uniforms.kernelRadius.value=4,this.overlayMaterial=this.getOverlayMaterial();const a=nt;this.copyUniforms=t.UniformsUtils.clone(a.uniforms),this.materialCopy=new t.ShaderMaterial({uniforms:this.copyUniforms,vertexShader:a.vertexShader,fragmentShader:a.fragmentShader,blending:t.NoBlending,depthTest:!1,depthWrite:!1}),this.enabled=!0,this.needsSwap=!1,this._oldClearColor=new t.Color,this.oldClearAlpha=1,this.fsQuad=new lt(null),this.tempPulseColor1=new t.Color,this.tempPulseColor2=new t.Color,this.textureMatrix=new t.Matrix4}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(e,t){this.renderTargetMaskBuffer.setSize(e,t),this.renderTargetDepthBuffer.setSize(e,t);let r=Math.round(e/this.downSampleRatio),n=Math.round(t/this.downSampleRatio);this.renderTargetMaskDownSampleBuffer.setSize(r,n),this.renderTargetBlurBuffer1.setSize(r,n),this.renderTargetEdgeBuffer1.setSize(r,n),this.separableBlurMaterial1.uniforms.texSize.value.set(r,n),r=Math.round(r/2),n=Math.round(n/2),this.renderTargetBlurBuffer2.setSize(r,n),this.renderTargetEdgeBuffer2.setSize(r,n),this.separableBlurMaterial2.uniforms.texSize.value.set(r,n)}changeVisibilityOfSelectedObjects(e){const t=this._visibilityCache;function r(r){r.isMesh&&(!0===e?r.visible=t.get(r):(t.set(r,r.visible),r.visible=e))}for(let e=0;e0){e.getClearColor(this._oldClearColor),this.oldClearAlpha=e.getClearAlpha();const t=e.autoClear;e.autoClear=!1,s&&e.state.buffers.stencil.setTest(!1),e.setClearColor(16777215,1),this.changeVisibilityOfSelectedObjects(!1);const n=this.renderScene.background;if(this.renderScene.background=null,this.renderScene.overrideMaterial=this.depthMaterial,e.setRenderTarget(this.renderTargetDepthBuffer),e.clear(),e.render(this.renderScene,this.renderCamera),this.changeVisibilityOfSelectedObjects(!0),this._visibilityCache.clear(),this.updateTextureMatrix(),this.changeVisibilityOfNonSelectedObjects(!1),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,e.setRenderTarget(this.renderTargetMaskBuffer),e.clear(),e.render(this.renderScene,this.renderCamera),this.renderScene.overrideMaterial=null,this.changeVisibilityOfNonSelectedObjects(!0),this._visibilityCache.clear(),this.renderScene.background=n,this.fsQuad.material=this.materialCopy,this.copyUniforms.tDiffuse.value=this.renderTargetMaskBuffer.texture,e.setRenderTarget(this.renderTargetMaskDownSampleBuffer),e.clear(),this.fsQuad.render(e),this.tempPulseColor1.copy(this.visibleEdgeColor),this.tempPulseColor2.copy(this.hiddenEdgeColor),this.pulsePeriod>0){const e=.625+.75*Math.cos(.01*performance.now()/this.pulsePeriod)/2;this.tempPulseColor1.multiplyScalar(e),this.tempPulseColor2.multiplyScalar(e)}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,e.setRenderTarget(this.renderTargetEdgeBuffer1),e.clear(),this.fsQuad.render(e),this.fsQuad.material=this.separableBlurMaterial1,this.separableBlurMaterial1.uniforms.colorTexture.value=this.renderTargetEdgeBuffer1.texture,this.separableBlurMaterial1.uniforms.direction.value=dt.BlurDirectionX,this.separableBlurMaterial1.uniforms.kernelRadius.value=this.edgeThickness,e.setRenderTarget(this.renderTargetBlurBuffer1),e.clear(),this.fsQuad.render(e),this.separableBlurMaterial1.uniforms.colorTexture.value=this.renderTargetBlurBuffer1.texture,this.separableBlurMaterial1.uniforms.direction.value=dt.BlurDirectionY,e.setRenderTarget(this.renderTargetEdgeBuffer1),e.clear(),this.fsQuad.render(e),this.fsQuad.material=this.separableBlurMaterial2,this.separableBlurMaterial2.uniforms.colorTexture.value=this.renderTargetEdgeBuffer1.texture,this.separableBlurMaterial2.uniforms.direction.value=dt.BlurDirectionX,e.setRenderTarget(this.renderTargetBlurBuffer2),e.clear(),this.fsQuad.render(e),this.separableBlurMaterial2.uniforms.colorTexture.value=this.renderTargetBlurBuffer2.texture,this.separableBlurMaterial2.uniforms.direction.value=dt.BlurDirectionY,e.setRenderTarget(this.renderTargetEdgeBuffer2),e.clear(),this.fsQuad.render(e),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,s&&e.state.buffers.stencil.setTest(!0),e.setRenderTarget(r),this.fsQuad.render(e),e.setClearColor(this._oldClearColor,this.oldClearAlpha),e.autoClear=t}this.renderToScreen&&(this.fsQuad.material=this.materialCopy,this.copyUniforms.tDiffuse.value=r.texture,e.setRenderTarget(null),this.fsQuad.render(e))}getPrepareMaskMaterial(){return new t.ShaderMaterial({uniforms:{depthTexture:{value:null},cameraNearFar:{value:new t.Vector2(.5,.5)},textureMatrix:{value:null}},vertexShader:"#include \n\t\t\t\t#include \n\n\t\t\t\tvarying vec4 projTexCoord;\n\t\t\t\tvarying vec4 vPosition;\n\t\t\t\tuniform mat4 textureMatrix;\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\n\t\t\t\t\tvPosition = mvPosition;\n\n\t\t\t\t\tvec4 worldPosition = vec4( transformed, 1.0 );\n\n\t\t\t\t\t#ifdef USE_INSTANCING\n\n\t\t\t\t\t\tworldPosition = instanceMatrix * worldPosition;\n\n\t\t\t\t\t#endif\n\n\t\t\t\t\tworldPosition = modelMatrix * worldPosition;\n\n\t\t\t\t\tprojTexCoord = textureMatrix * worldPosition;\n\n\t\t\t\t}",fragmentShader:"#include \n\t\t\t\tvarying vec4 vPosition;\n\t\t\t\tvarying vec4 projTexCoord;\n\t\t\t\tuniform sampler2D depthTexture;\n\t\t\t\tuniform vec2 cameraNearFar;\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tfloat depth = unpackRGBAToDepth(texture2DProj( depthTexture, projTexCoord ));\n\t\t\t\t\tfloat viewZ = - DEPTH_TO_VIEW_Z( depth, cameraNearFar.x, cameraNearFar.y );\n\t\t\t\t\tfloat depthTest = (-vPosition.z > viewZ) ? 1.0 : 0.0;\n\t\t\t\t\tgl_FragColor = vec4(0.0, depthTest, 1.0, 1.0);\n\n\t\t\t\t}"})}getEdgeDetectionMaterial(){return new t.ShaderMaterial({uniforms:{maskTexture:{value:null},texSize:{value:new t.Vector2(.5,.5)},visibleEdgeColor:{value:new t.Vector3(1,1,1)},hiddenEdgeColor:{value:new t.Vector3(1,1,1)}},vertexShader:"varying vec2 vUv;\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tvUv = uv;\n\t\t\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\t\t\t\t}",fragmentShader:"varying vec2 vUv;\n\n\t\t\t\tuniform sampler2D maskTexture;\n\t\t\t\tuniform vec2 texSize;\n\t\t\t\tuniform vec3 visibleEdgeColor;\n\t\t\t\tuniform vec3 hiddenEdgeColor;\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tvec2 invSize = 1.0 / texSize;\n\t\t\t\t\tvec4 uvOffset = vec4(1.0, 0.0, 0.0, 1.0) * vec4(invSize, invSize);\n\t\t\t\t\tvec4 c1 = texture2D( maskTexture, vUv + uvOffset.xy);\n\t\t\t\t\tvec4 c2 = texture2D( maskTexture, vUv - uvOffset.xy);\n\t\t\t\t\tvec4 c3 = texture2D( maskTexture, vUv + uvOffset.yw);\n\t\t\t\t\tvec4 c4 = texture2D( maskTexture, vUv - uvOffset.yw);\n\t\t\t\t\tfloat diff1 = (c1.r - c2.r)*0.5;\n\t\t\t\t\tfloat diff2 = (c3.r - c4.r)*0.5;\n\t\t\t\t\tfloat d = length( vec2(diff1, diff2) );\n\t\t\t\t\tfloat a1 = min(c1.g, c2.g);\n\t\t\t\t\tfloat a2 = min(c3.g, c4.g);\n\t\t\t\t\tfloat visibilityFactor = min(a1, a2);\n\t\t\t\t\tvec3 edgeColor = 1.0 - visibilityFactor > 0.001 ? visibleEdgeColor : hiddenEdgeColor;\n\t\t\t\t\tgl_FragColor = vec4(edgeColor, 1.0) * vec4(d);\n\t\t\t\t}"})}getSeperableBlurMaterial(e){return new t.ShaderMaterial({defines:{MAX_RADIUS:e},uniforms:{colorTexture:{value:null},texSize:{value:new t.Vector2(.5,.5)},direction:{value:new t.Vector2(.5,.5)},kernelRadius:{value:1}},vertexShader:"varying vec2 vUv;\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tvUv = uv;\n\t\t\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\t\t\t\t}",fragmentShader:"#include \n\t\t\t\tvarying vec2 vUv;\n\t\t\t\tuniform sampler2D colorTexture;\n\t\t\t\tuniform vec2 texSize;\n\t\t\t\tuniform vec2 direction;\n\t\t\t\tuniform float kernelRadius;\n\n\t\t\t\tfloat gaussianPdf(in float x, in float sigma) {\n\t\t\t\t\treturn 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tvec2 invSize = 1.0 / texSize;\n\t\t\t\t\tfloat sigma = kernelRadius/2.0;\n\t\t\t\t\tfloat weightSum = gaussianPdf(0.0, sigma);\n\t\t\t\t\tvec4 diffuseSum = texture2D( colorTexture, vUv) * weightSum;\n\t\t\t\t\tvec2 delta = direction * invSize * kernelRadius/float(MAX_RADIUS);\n\t\t\t\t\tvec2 uvOffset = delta;\n\t\t\t\t\tfor( int i = 1; i <= MAX_RADIUS; i ++ ) {\n\t\t\t\t\t\tfloat x = kernelRadius * float(i) / float(MAX_RADIUS);\n\t\t\t\t\t\tfloat w = gaussianPdf(x, sigma);\n\t\t\t\t\t\tvec4 sample1 = texture2D( colorTexture, vUv + uvOffset);\n\t\t\t\t\t\tvec4 sample2 = texture2D( colorTexture, vUv - uvOffset);\n\t\t\t\t\t\tdiffuseSum += ((sample1 + sample2) * w);\n\t\t\t\t\t\tweightSum += (2.0 * w);\n\t\t\t\t\t\tuvOffset += delta;\n\t\t\t\t\t}\n\t\t\t\t\tgl_FragColor = diffuseSum/weightSum;\n\t\t\t\t}"})}getOverlayMaterial(){return new t.ShaderMaterial({uniforms:{maskTexture:{value:null},edgeTexture1:{value:null},edgeTexture2:{value:null},patternTexture:{value:null},edgeStrength:{value:1},edgeGlow:{value:1},usePatternTexture:{value:0}},vertexShader:"varying vec2 vUv;\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tvUv = uv;\n\t\t\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\t\t\t\t}",fragmentShader:"varying vec2 vUv;\n\n\t\t\t\tuniform sampler2D maskTexture;\n\t\t\t\tuniform sampler2D edgeTexture1;\n\t\t\t\tuniform sampler2D edgeTexture2;\n\t\t\t\tuniform sampler2D patternTexture;\n\t\t\t\tuniform float edgeStrength;\n\t\t\t\tuniform float edgeGlow;\n\t\t\t\tuniform bool usePatternTexture;\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tvec4 edgeValue1 = texture2D(edgeTexture1, vUv);\n\t\t\t\t\tvec4 edgeValue2 = texture2D(edgeTexture2, vUv);\n\t\t\t\t\tvec4 maskColor = texture2D(maskTexture, vUv);\n\t\t\t\t\tvec4 patternColor = texture2D(patternTexture, 6.0 * vUv);\n\t\t\t\t\tfloat visibilityFactor = 1.0 - maskColor.g > 0.0 ? 1.0 : 0.5;\n\t\t\t\t\tvec4 edgeValue = edgeValue1 + edgeValue2 * edgeGlow;\n\t\t\t\t\tvec4 finalColor = edgeStrength * maskColor.r * edgeValue;\n\t\t\t\t\tif(usePatternTexture)\n\t\t\t\t\t\tfinalColor += + visibilityFactor * (1.0 - maskColor.r) * (1.0 - patternColor.r);\n\t\t\t\t\tgl_FragColor = finalColor;\n\t\t\t\t}",blending:t.AdditiveBlending,depthTest:!1,depthWrite:!1,transparent:!0})}}dt.BlurDirectionX=new t.Vector2(1,0),dt.BlurDirectionY=new t.Vector2(0,1);const pt={name:"SMAAEdgesShader",defines:{SMAA_THRESHOLD:"0.1"},uniforms:{tDiffuse:{value:null},resolution:{value:new t.Vector2(1/1024,1/512)}},vertexShader:"\n\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 3 ];\n\n\t\tvoid SMAAEdgeDetectionVS( vec2 texcoord ) {\n\t\t\tvOffset[ 0 ] = texcoord.xyxy + resolution.xyxy * vec4( -1.0, 0.0, 0.0, 1.0 ); // WebGL port note: Changed sign in W component\n\t\t\tvOffset[ 1 ] = texcoord.xyxy + resolution.xyxy * vec4( 1.0, 0.0, 0.0, -1.0 ); // WebGL port note: Changed sign in W component\n\t\t\tvOffset[ 2 ] = texcoord.xyxy + resolution.xyxy * vec4( -2.0, 0.0, 0.0, 2.0 ); // WebGL port note: Changed sign in W component\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tSMAAEdgeDetectionVS( vUv );\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 3 ];\n\n\t\tvec4 SMAAColorEdgeDetectionPS( vec2 texcoord, vec4 offset[3], sampler2D colorTex ) {\n\t\t\tvec2 threshold = vec2( SMAA_THRESHOLD, SMAA_THRESHOLD );\n\n\t\t\t// Calculate color deltas:\n\t\t\tvec4 delta;\n\t\t\tvec3 C = texture2D( colorTex, texcoord ).rgb;\n\n\t\t\tvec3 Cleft = texture2D( colorTex, offset[0].xy ).rgb;\n\t\t\tvec3 t = abs( C - Cleft );\n\t\t\tdelta.x = max( max( t.r, t.g ), t.b );\n\n\t\t\tvec3 Ctop = texture2D( colorTex, offset[0].zw ).rgb;\n\t\t\tt = abs( C - Ctop );\n\t\t\tdelta.y = max( max( t.r, t.g ), t.b );\n\n\t\t\t// We do the usual threshold:\n\t\t\tvec2 edges = step( threshold, delta.xy );\n\n\t\t\t// Then discard if there is no edge:\n\t\t\tif ( dot( edges, vec2( 1.0, 1.0 ) ) == 0.0 )\n\t\t\t\tdiscard;\n\n\t\t\t// Calculate right and bottom deltas:\n\t\t\tvec3 Cright = texture2D( colorTex, offset[1].xy ).rgb;\n\t\t\tt = abs( C - Cright );\n\t\t\tdelta.z = max( max( t.r, t.g ), t.b );\n\n\t\t\tvec3 Cbottom = texture2D( colorTex, offset[1].zw ).rgb;\n\t\t\tt = abs( C - Cbottom );\n\t\t\tdelta.w = max( max( t.r, t.g ), t.b );\n\n\t\t\t// Calculate the maximum delta in the direct neighborhood:\n\t\t\tfloat maxDelta = max( max( max( delta.x, delta.y ), delta.z ), delta.w );\n\n\t\t\t// Calculate left-left and top-top deltas:\n\t\t\tvec3 Cleftleft = texture2D( colorTex, offset[2].xy ).rgb;\n\t\t\tt = abs( C - Cleftleft );\n\t\t\tdelta.z = max( max( t.r, t.g ), t.b );\n\n\t\t\tvec3 Ctoptop = texture2D( colorTex, offset[2].zw ).rgb;\n\t\t\tt = abs( C - Ctoptop );\n\t\t\tdelta.w = max( max( t.r, t.g ), t.b );\n\n\t\t\t// Calculate the final maximum delta:\n\t\t\tmaxDelta = max( max( maxDelta, delta.z ), delta.w );\n\n\t\t\t// Local contrast adaptation in action:\n\t\t\tedges.xy *= step( 0.5 * maxDelta, delta.xy );\n\n\t\t\treturn vec4( edges, 0.0, 0.0 );\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = SMAAColorEdgeDetectionPS( vUv, vOffset, tDiffuse );\n\n\t\t}"},ft={name:"SMAAWeightsShader",defines:{SMAA_MAX_SEARCH_STEPS:"8",SMAA_AREATEX_MAX_DISTANCE:"16",SMAA_AREATEX_PIXEL_SIZE:"( 1.0 / vec2( 160.0, 560.0 ) )",SMAA_AREATEX_SUBTEX_SIZE:"( 1.0 / 7.0 )"},uniforms:{tDiffuse:{value:null},tArea:{value:null},tSearch:{value:null},resolution:{value:new t.Vector2(1/1024,1/512)}},vertexShader:"\n\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 3 ];\n\t\tvarying vec2 vPixcoord;\n\n\t\tvoid SMAABlendingWeightCalculationVS( vec2 texcoord ) {\n\t\t\tvPixcoord = texcoord / resolution;\n\n\t\t\t// We will use these offsets for the searches later on (see @PSEUDO_GATHER4):\n\t\t\tvOffset[ 0 ] = texcoord.xyxy + resolution.xyxy * vec4( -0.25, 0.125, 1.25, 0.125 ); // WebGL port note: Changed sign in Y and W components\n\t\t\tvOffset[ 1 ] = texcoord.xyxy + resolution.xyxy * vec4( -0.125, 0.25, -0.125, -1.25 ); // WebGL port note: Changed sign in Y and W components\n\n\t\t\t// And these for the searches, they indicate the ends of the loops:\n\t\t\tvOffset[ 2 ] = vec4( vOffset[ 0 ].xz, vOffset[ 1 ].yw ) + vec4( -2.0, 2.0, -2.0, 2.0 ) * resolution.xxyy * float( SMAA_MAX_SEARCH_STEPS );\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tSMAABlendingWeightCalculationVS( vUv );\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\t#define SMAASampleLevelZeroOffset( tex, coord, offset ) texture2D( tex, coord + float( offset ) * resolution, 0.0 )\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform sampler2D tArea;\n\t\tuniform sampler2D tSearch;\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[3];\n\t\tvarying vec2 vPixcoord;\n\n\t\t#if __VERSION__ == 100\n\t\tvec2 round( vec2 x ) {\n\t\t\treturn sign( x ) * floor( abs( x ) + 0.5 );\n\t\t}\n\t\t#endif\n\n\t\tfloat SMAASearchLength( sampler2D searchTex, vec2 e, float bias, float scale ) {\n\t\t\t// Not required if searchTex accesses are set to point:\n\t\t\t// float2 SEARCH_TEX_PIXEL_SIZE = 1.0 / float2(66.0, 33.0);\n\t\t\t// e = float2(bias, 0.0) + 0.5 * SEARCH_TEX_PIXEL_SIZE +\n\t\t\t// e * float2(scale, 1.0) * float2(64.0, 32.0) * SEARCH_TEX_PIXEL_SIZE;\n\t\t\te.r = bias + e.r * scale;\n\t\t\treturn 255.0 * texture2D( searchTex, e, 0.0 ).r;\n\t\t}\n\n\t\tfloat SMAASearchXLeft( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\t/**\n\t\t\t\t* @PSEUDO_GATHER4\n\t\t\t\t* This texcoord has been offset by (-0.25, -0.125) in the vertex shader to\n\t\t\t\t* sample between edge, thus fetching four edges in a row.\n\t\t\t\t* Sampling with different offsets in each direction allows to disambiguate\n\t\t\t\t* which edges are active from the four fetched ones.\n\t\t\t\t*/\n\t\t\tvec2 e = vec2( 0.0, 1.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord -= vec2( 2.0, 0.0 ) * resolution;\n\t\t\t\tif ( ! ( texcoord.x > end && e.g > 0.8281 && e.r == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\t// We correct the previous (-0.25, -0.125) offset we applied:\n\t\t\ttexcoord.x += 0.25 * resolution.x;\n\n\t\t\t// The searches are bias by 1, so adjust the coords accordingly:\n\t\t\ttexcoord.x += resolution.x;\n\n\t\t\t// Disambiguate the length added by the last step:\n\t\t\ttexcoord.x += 2.0 * resolution.x; // Undo last step\n\t\t\ttexcoord.x -= resolution.x * SMAASearchLength(searchTex, e, 0.0, 0.5);\n\n\t\t\treturn texcoord.x;\n\t\t}\n\n\t\tfloat SMAASearchXRight( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\tvec2 e = vec2( 0.0, 1.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord += vec2( 2.0, 0.0 ) * resolution;\n\t\t\t\tif ( ! ( texcoord.x < end && e.g > 0.8281 && e.r == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\ttexcoord.x -= 0.25 * resolution.x;\n\t\t\ttexcoord.x -= resolution.x;\n\t\t\ttexcoord.x -= 2.0 * resolution.x;\n\t\t\ttexcoord.x += resolution.x * SMAASearchLength( searchTex, e, 0.5, 0.5 );\n\n\t\t\treturn texcoord.x;\n\t\t}\n\n\t\tfloat SMAASearchYUp( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\tvec2 e = vec2( 1.0, 0.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord += vec2( 0.0, 2.0 ) * resolution; // WebGL port note: Changed sign\n\t\t\t\tif ( ! ( texcoord.y > end && e.r > 0.8281 && e.g == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\ttexcoord.y -= 0.25 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y -= resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y -= 2.0 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y += resolution.y * SMAASearchLength( searchTex, e.gr, 0.0, 0.5 ); // WebGL port note: Changed sign\n\n\t\t\treturn texcoord.y;\n\t\t}\n\n\t\tfloat SMAASearchYDown( sampler2D edgesTex, sampler2D searchTex, vec2 texcoord, float end ) {\n\t\t\tvec2 e = vec2( 1.0, 0.0 );\n\n\t\t\tfor ( int i = 0; i < SMAA_MAX_SEARCH_STEPS; i ++ ) { // WebGL port note: Changed while to for\n\t\t\t\te = texture2D( edgesTex, texcoord, 0.0 ).rg;\n\t\t\t\ttexcoord -= vec2( 0.0, 2.0 ) * resolution; // WebGL port note: Changed sign\n\t\t\t\tif ( ! ( texcoord.y < end && e.r > 0.8281 && e.g == 0.0 ) ) break;\n\t\t\t}\n\n\t\t\ttexcoord.y += 0.25 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y += resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y += 2.0 * resolution.y; // WebGL port note: Changed sign\n\t\t\ttexcoord.y -= resolution.y * SMAASearchLength( searchTex, e.gr, 0.5, 0.5 ); // WebGL port note: Changed sign\n\n\t\t\treturn texcoord.y;\n\t\t}\n\n\t\tvec2 SMAAArea( sampler2D areaTex, vec2 dist, float e1, float e2, float offset ) {\n\t\t\t// Rounding prevents precision errors of bilinear filtering:\n\t\t\tvec2 texcoord = float( SMAA_AREATEX_MAX_DISTANCE ) * round( 4.0 * vec2( e1, e2 ) ) + dist;\n\n\t\t\t// We do a scale and bias for mapping to texel space:\n\t\t\ttexcoord = SMAA_AREATEX_PIXEL_SIZE * texcoord + ( 0.5 * SMAA_AREATEX_PIXEL_SIZE );\n\n\t\t\t// Move to proper place, according to the subpixel offset:\n\t\t\ttexcoord.y += SMAA_AREATEX_SUBTEX_SIZE * offset;\n\n\t\t\treturn texture2D( areaTex, texcoord, 0.0 ).rg;\n\t\t}\n\n\t\tvec4 SMAABlendingWeightCalculationPS( vec2 texcoord, vec2 pixcoord, vec4 offset[ 3 ], sampler2D edgesTex, sampler2D areaTex, sampler2D searchTex, ivec4 subsampleIndices ) {\n\t\t\tvec4 weights = vec4( 0.0, 0.0, 0.0, 0.0 );\n\n\t\t\tvec2 e = texture2D( edgesTex, texcoord ).rg;\n\n\t\t\tif ( e.g > 0.0 ) { // Edge at north\n\t\t\t\tvec2 d;\n\n\t\t\t\t// Find the distance to the left:\n\t\t\t\tvec2 coords;\n\t\t\t\tcoords.x = SMAASearchXLeft( edgesTex, searchTex, offset[ 0 ].xy, offset[ 2 ].x );\n\t\t\t\tcoords.y = offset[ 1 ].y; // offset[1].y = texcoord.y - 0.25 * resolution.y (@CROSSING_OFFSET)\n\t\t\t\td.x = coords.x;\n\n\t\t\t\t// Now fetch the left crossing edges, two at a time using bilinear\n\t\t\t\t// filtering. Sampling at -0.25 (see @CROSSING_OFFSET) enables to\n\t\t\t\t// discern what value each edge has:\n\t\t\t\tfloat e1 = texture2D( edgesTex, coords, 0.0 ).r;\n\n\t\t\t\t// Find the distance to the right:\n\t\t\t\tcoords.x = SMAASearchXRight( edgesTex, searchTex, offset[ 0 ].zw, offset[ 2 ].y );\n\t\t\t\td.y = coords.x;\n\n\t\t\t\t// We want the distances to be in pixel units (doing this here allow to\n\t\t\t\t// better interleave arithmetic and memory accesses):\n\t\t\t\td = d / resolution.x - pixcoord.x;\n\n\t\t\t\t// SMAAArea below needs a sqrt, as the areas texture is compressed\n\t\t\t\t// quadratically:\n\t\t\t\tvec2 sqrt_d = sqrt( abs( d ) );\n\n\t\t\t\t// Fetch the right crossing edges:\n\t\t\t\tcoords.y -= 1.0 * resolution.y; // WebGL port note: Added\n\t\t\t\tfloat e2 = SMAASampleLevelZeroOffset( edgesTex, coords, ivec2( 1, 0 ) ).r;\n\n\t\t\t\t// Ok, we know how this pattern looks like, now it is time for getting\n\t\t\t\t// the actual area:\n\t\t\t\tweights.rg = SMAAArea( areaTex, sqrt_d, e1, e2, float( subsampleIndices.y ) );\n\t\t\t}\n\n\t\t\tif ( e.r > 0.0 ) { // Edge at west\n\t\t\t\tvec2 d;\n\n\t\t\t\t// Find the distance to the top:\n\t\t\t\tvec2 coords;\n\n\t\t\t\tcoords.y = SMAASearchYUp( edgesTex, searchTex, offset[ 1 ].xy, offset[ 2 ].z );\n\t\t\t\tcoords.x = offset[ 0 ].x; // offset[1].x = texcoord.x - 0.25 * resolution.x;\n\t\t\t\td.x = coords.y;\n\n\t\t\t\t// Fetch the top crossing edges:\n\t\t\t\tfloat e1 = texture2D( edgesTex, coords, 0.0 ).g;\n\n\t\t\t\t// Find the distance to the bottom:\n\t\t\t\tcoords.y = SMAASearchYDown( edgesTex, searchTex, offset[ 1 ].zw, offset[ 2 ].w );\n\t\t\t\td.y = coords.y;\n\n\t\t\t\t// We want the distances to be in pixel units:\n\t\t\t\td = d / resolution.y - pixcoord.y;\n\n\t\t\t\t// SMAAArea below needs a sqrt, as the areas texture is compressed\n\t\t\t\t// quadratically:\n\t\t\t\tvec2 sqrt_d = sqrt( abs( d ) );\n\n\t\t\t\t// Fetch the bottom crossing edges:\n\t\t\t\tcoords.y -= 1.0 * resolution.y; // WebGL port note: Added\n\t\t\t\tfloat e2 = SMAASampleLevelZeroOffset( edgesTex, coords, ivec2( 0, 1 ) ).g;\n\n\t\t\t\t// Get the area for this direction:\n\t\t\t\tweights.ba = SMAAArea( areaTex, sqrt_d, e1, e2, float( subsampleIndices.x ) );\n\t\t\t}\n\n\t\t\treturn weights;\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = SMAABlendingWeightCalculationPS( vUv, vPixcoord, vOffset, tDiffuse, tArea, tSearch, ivec4( 0.0 ) );\n\n\t\t}"},mt={name:"SMAABlendShader",uniforms:{tDiffuse:{value:null},tColor:{value:null},resolution:{value:new t.Vector2(1/1024,1/512)}},vertexShader:"\n\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 2 ];\n\n\t\tvoid SMAANeighborhoodBlendingVS( vec2 texcoord ) {\n\t\t\tvOffset[ 0 ] = texcoord.xyxy + resolution.xyxy * vec4( -1.0, 0.0, 0.0, 1.0 ); // WebGL port note: Changed sign in W component\n\t\t\tvOffset[ 1 ] = texcoord.xyxy + resolution.xyxy * vec4( 1.0, 0.0, 0.0, -1.0 ); // WebGL port note: Changed sign in W component\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tSMAANeighborhoodBlendingVS( vUv );\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform sampler2D tColor;\n\t\tuniform vec2 resolution;\n\n\t\tvarying vec2 vUv;\n\t\tvarying vec4 vOffset[ 2 ];\n\n\t\tvec4 SMAANeighborhoodBlendingPS( vec2 texcoord, vec4 offset[ 2 ], sampler2D colorTex, sampler2D blendTex ) {\n\t\t\t// Fetch the blending weights for current pixel:\n\t\t\tvec4 a;\n\t\t\ta.xz = texture2D( blendTex, texcoord ).xz;\n\t\t\ta.y = texture2D( blendTex, offset[ 1 ].zw ).g;\n\t\t\ta.w = texture2D( blendTex, offset[ 1 ].xy ).a;\n\n\t\t\t// Is there any blending weight with a value greater than 0.0?\n\t\t\tif ( dot(a, vec4( 1.0, 1.0, 1.0, 1.0 )) < 1e-5 ) {\n\t\t\t\treturn texture2D( colorTex, texcoord, 0.0 );\n\t\t\t} else {\n\t\t\t\t// Up to 4 lines can be crossing a pixel (one through each edge). We\n\t\t\t\t// favor blending by choosing the line with the maximum weight for each\n\t\t\t\t// direction:\n\t\t\t\tvec2 offset;\n\t\t\t\toffset.x = a.a > a.b ? a.a : -a.b; // left vs. right\n\t\t\t\toffset.y = a.g > a.r ? -a.g : a.r; // top vs. bottom // WebGL port note: Changed signs\n\n\t\t\t\t// Then we go in the direction that has the maximum weight:\n\t\t\t\tif ( abs( offset.x ) > abs( offset.y )) { // horizontal vs. vertical\n\t\t\t\t\toffset.y = 0.0;\n\t\t\t\t} else {\n\t\t\t\t\toffset.x = 0.0;\n\t\t\t\t}\n\n\t\t\t\t// Fetch the opposite color and lerp by hand:\n\t\t\t\tvec4 C = texture2D( colorTex, texcoord, 0.0 );\n\t\t\t\ttexcoord += sign( offset ) * resolution;\n\t\t\t\tvec4 Cop = texture2D( colorTex, texcoord, 0.0 );\n\t\t\t\tfloat s = abs( offset.x ) > abs( offset.y ) ? abs( offset.x ) : abs( offset.y );\n\n\t\t\t\t// WebGL port note: Added gamma correction\n\t\t\t\tC.xyz = pow(C.xyz, vec3(2.2));\n\t\t\t\tCop.xyz = pow(Cop.xyz, vec3(2.2));\n\t\t\t\tvec4 mixed = mix(C, Cop, s);\n\t\t\t\tmixed.xyz = pow(mixed.xyz, vec3(1.0 / 2.2));\n\n\t\t\t\treturn mixed;\n\t\t\t}\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = SMAANeighborhoodBlendingPS( vUv, vOffset, tColor, tDiffuse );\n\n\t\t}"};const gt={name:"OutputShader",uniforms:{tDiffuse:{value:null},toneMappingExposure:{value:1}},vertexShader:"\n\t\tprecision highp float;\n\n\t\tuniform mat4 modelViewMatrix;\n\t\tuniform mat4 projectionMatrix;\n\n\t\tattribute vec3 position;\n\t\tattribute vec2 uv;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\t\n\t\tprecision highp float;\n\n\t\tuniform sampler2D tDiffuse;\n\n\t\t#include \n\t\t#include \n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\n\t\t\t// tone mapping\n\n\t\t\t#ifdef LINEAR_TONE_MAPPING\n\n\t\t\t\tgl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( REINHARD_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( CINEON_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = OptimizedCineonToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( ACES_FILMIC_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb );\n\n\t\t\t#elif defined( AGX_TONE_MAPPING )\n\n\t\t\t\tgl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb );\n\n\t\t\t#endif\n\n\t\t\t// color space\n\n\t\t\t#ifdef SRGB_TRANSFER\n\n\t\t\t\tgl_FragColor = sRGBTransferOETF( gl_FragColor );\n\n\t\t\t#endif\n\n\t\t}"};const xt={name:"LuminosityHighPassShader",shaderID:"luminosityHighPass",uniforms:{tDiffuse:{value:null},luminosityThreshold:{value:1},smoothWidth:{value:1},defaultColor:{value:new t.Color(0)},defaultOpacity:{value:0}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform vec3 defaultColor;\n\t\tuniform float defaultOpacity;\n\t\tuniform float luminosityThreshold;\n\t\tuniform float smoothWidth;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvec4 texel = texture2D( tDiffuse, vUv );\n\n\t\t\tvec3 luma = vec3( 0.299, 0.587, 0.114 );\n\n\t\t\tfloat v = dot( texel.xyz, luma );\n\n\t\t\tvec4 outputColor = vec4( defaultColor.rgb, defaultOpacity );\n\n\t\t\tfloat alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );\n\n\t\t\tgl_FragColor = mix( outputColor, texel, alpha );\n\n\t\t}"};class vt extends st{constructor(e,r,n,s){super(),this.strength=void 0!==r?r:1,this.radius=n,this.threshold=s,this.resolution=void 0!==e?new t.Vector2(e.x,e.y):new t.Vector2(256,256),this.clearColor=new t.Color(0,0,0),this.renderTargetsHorizontal=[],this.renderTargetsVertical=[],this.nMips=5;let i=Math.round(this.resolution.x/2),o=Math.round(this.resolution.y/2);this.renderTargetBright=new t.WebGLRenderTarget(i,o,{type:t.HalfFloatType}),this.renderTargetBright.texture.name="UnrealBloomPass.bright",this.renderTargetBright.texture.generateMipmaps=!1;for(let e=0;e\n\t\t\t\tvarying vec2 vUv;\n\t\t\t\tuniform sampler2D colorTexture;\n\t\t\t\tuniform vec2 invSize;\n\t\t\t\tuniform vec2 direction;\n\t\t\t\tuniform float gaussianCoefficients[KERNEL_RADIUS];\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tfloat weightSum = gaussianCoefficients[0];\n\t\t\t\t\tvec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;\n\t\t\t\t\tfor( int i = 1; i < KERNEL_RADIUS; i ++ ) {\n\t\t\t\t\t\tfloat x = float(i);\n\t\t\t\t\t\tfloat w = gaussianCoefficients[i];\n\t\t\t\t\t\tvec2 uvOffset = direction * invSize * x;\n\t\t\t\t\t\tvec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb;\n\t\t\t\t\t\tvec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb;\n\t\t\t\t\t\tdiffuseSum += (sample1 + sample2) * w;\n\t\t\t\t\t\tweightSum += 2.0 * w;\n\t\t\t\t\t}\n\t\t\t\t\tgl_FragColor = vec4(diffuseSum/weightSum, 1.0);\n\t\t\t\t}"})}getCompositeMaterial(e){return new t.ShaderMaterial({defines:{NUM_MIPS:e},uniforms:{blurTexture1:{value:null},blurTexture2:{value:null},blurTexture3:{value:null},blurTexture4:{value:null},blurTexture5:{value:null},bloomStrength:{value:1},bloomFactors:{value:null},bloomTintColors:{value:null},bloomRadius:{value:0}},vertexShader:"varying vec2 vUv;\n\t\t\t\tvoid main() {\n\t\t\t\t\tvUv = uv;\n\t\t\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\t\t\t\t}",fragmentShader:"varying vec2 vUv;\n\t\t\t\tuniform sampler2D blurTexture1;\n\t\t\t\tuniform sampler2D blurTexture2;\n\t\t\t\tuniform sampler2D blurTexture3;\n\t\t\t\tuniform sampler2D blurTexture4;\n\t\t\t\tuniform sampler2D blurTexture5;\n\t\t\t\tuniform float bloomStrength;\n\t\t\t\tuniform float bloomRadius;\n\t\t\t\tuniform float bloomFactors[NUM_MIPS];\n\t\t\t\tuniform vec3 bloomTintColors[NUM_MIPS];\n\n\t\t\t\tfloat lerpBloomFactor(const in float factor) {\n\t\t\t\t\tfloat mirrorFactor = 1.2 - factor;\n\t\t\t\t\treturn mix(factor, mirrorFactor, bloomRadius);\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\t\t\t\t\tgl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +\n\t\t\t\t\t\tlerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );\n\t\t\t\t}"})}}vt.BlurDirectionX=new t.Vector2(1,0),vt.BlurDirectionY=new t.Vector2(0,1);const Tt={name:"BrightnessContrastShader",uniforms:{tDiffuse:{value:null},brightness:{value:0},contrast:{value:0}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform float brightness;\n\t\tuniform float contrast;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\n\t\t\tgl_FragColor.rgb += brightness;\n\n\t\t\tif (contrast > 0.0) {\n\t\t\t\tgl_FragColor.rgb = (gl_FragColor.rgb - 0.5) / (1.0 - contrast) + 0.5;\n\t\t\t} else {\n\t\t\t\tgl_FragColor.rgb = (gl_FragColor.rgb - 0.5) * (1.0 + contrast) + 0.5;\n\t\t\t}\n\n\t\t}"},Mt={name:"HueSaturationShader",uniforms:{tDiffuse:{value:null},hue:{value:0},saturation:{value:0}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform sampler2D tDiffuse;\n\t\tuniform float hue;\n\t\tuniform float saturation;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\n\t\t\t// hue\n\t\t\tfloat angle = hue * 3.14159265;\n\t\t\tfloat s = sin(angle), c = cos(angle);\n\t\t\tvec3 weights = (vec3(2.0 * c, -sqrt(3.0) * s - c, sqrt(3.0) * s - c) + 1.0) / 3.0;\n\t\t\tfloat len = length(gl_FragColor.rgb);\n\t\t\tgl_FragColor.rgb = vec3(\n\t\t\t\tdot(gl_FragColor.rgb, weights.xyz),\n\t\t\t\tdot(gl_FragColor.rgb, weights.zxy),\n\t\t\t\tdot(gl_FragColor.rgb, weights.yzx)\n\t\t\t);\n\n\t\t\t// saturation\n\t\t\tfloat average = (gl_FragColor.r + gl_FragColor.g + gl_FragColor.b) / 3.0;\n\t\t\tif (saturation > 0.0) {\n\t\t\t\tgl_FragColor.rgb += (average - gl_FragColor.rgb) * (1.0 - 1.0 / (1.001 - saturation));\n\t\t\t} else {\n\t\t\t\tgl_FragColor.rgb += (average - gl_FragColor.rgb) * (-saturation);\n\t\t\t}\n\n\t\t}"},At={name:"ExposureShader",uniforms:{tDiffuse:{value:null},exposure:{value:1}},vertexShader:"\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tvUv = uv;\n\t\t\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n\n\t\t}",fragmentShader:"\n\n\t\tuniform float exposure;\n\n\t\tuniform sampler2D tDiffuse;\n\n\t\tvarying vec2 vUv;\n\n\t\tvoid main() {\n\n\t\t\tgl_FragColor = texture2D( tDiffuse, vUv );\n\t\t\tgl_FragColor.rgb *= exposure;\n\n\t\t}"};e.BrightnessContrastShader=Tt,e.DRACOLoader=ee,e.EffectComposer=class{constructor(e,r){if(this.renderer=e,this._pixelRatio=e.getPixelRatio(),void 0===r){const n=e.getSize(new t.Vector2);this._width=n.width,this._height=n.height,(r=new t.WebGLRenderTarget(this._width*this._pixelRatio,this._height*this._pixelRatio,{type:t.HalfFloatType})).texture.name="EffectComposer.rt1"}else this._width=r.width,this._height=r.height;this.renderTarget1=r,this.renderTarget2=r.clone(),this.renderTarget2.texture.name="EffectComposer.rt2",this.writeBuffer=this.renderTarget1,this.readBuffer=this.renderTarget2,this.renderToScreen=!0,this.passes=[],this.copyPass=new ct(nt),this.copyPass.material.blending=t.NoBlending,this.clock=new t.Clock}swapBuffers(){const e=this.readBuffer;this.readBuffer=this.writeBuffer,this.writeBuffer=e}addPass(e){this.passes.push(e),e.setSize(this._width*this._pixelRatio,this._height*this._pixelRatio)}insertPass(e,t){this.passes.splice(t,0,e),e.setSize(this._width*this._pixelRatio,this._height*this._pixelRatio)}removePass(e){const t=this.passes.indexOf(e);-1!==t&&this.passes.splice(t,1)}isLastEnabledPass(e){for(let t=e+1;t0)for(let r=0;r = [ + // near plane edges + [0, 1], + [1, 2], + [2, 3], + [3, 0], + // far plane edges + [4, 5], + [5, 6], + [6, 7], + [7, 4], + // near↔far connections + [0, 4], + [1, 5], + [2, 6], + [3, 7], + ]; + + /** Normalized Device Coordinates corners for near (-1) and far (+1) planes (Three.js NDC: z=-1 near, z=+1 far). */ + const NDC_CORNERS: Array> = [ + // near + [-1, -1, -1], + [+1, -1, -1], + [+1, +1, -1], + [-1, +1, -1], + // far + [-1, -1, +1], + [+1, -1, +1], + [+1, +1, +1], + [-1, +1, +1], + ]; + + /** Sort convex polygon vertices around centroid to get consistent winding. */ + const sortConvexPolygon = (points: THREE.Vector3[]): THREE.Vector3[] => { + if (points.length <= 2) return points; + const cx = points.reduce((s, p) => s + p.x, 0) / points.length; + const cy = points.reduce((s, p) => s + p.y, 0) / points.length; + return points + .map((p) => ({ p, a: Math.atan2(p.y - cy, p.x - cx) })) + .sort((u, v) => u.a - v.a) + .map((u) => u.p); + }; + + /** + * Intersect a frustum edge segment [a,b] with plane Z=0. + * Returns point or null if no intersection on the segment. + */ + const intersectSegmentWithZ0 = ( + a: THREE.Vector3, + b: THREE.Vector3, + eps = 1e-9 + ): THREE.Vector3 | null => { + const az = a.z, + bz = b.z; + const dz = bz - az; + + // If both z on same side and not on plane, no crossing. + if (Math.abs(dz) < eps) { + // Segment is (almost) parallel to plane. + if (Math.abs(az) < eps && Math.abs(bz) < eps) { + // Entire segment lies on plane: return endpoints (handled by caller via dedup). + // Here we return null and let caller add endpoints if needed. + return null; + } + return null; + } + + // Solve a.z + t*(b.z - a.z) = 0 ⇒ t = -a.z / (b.z - a.z) + const t = -az / dz; + if (t < -eps || t > 1 + eps) { + // Intersection beyond the segment bounds. + return null; + } + + const p = new THREE.Vector3( + a.x + t * (b.x - a.x), + a.y + t * (b.y - a.y), + 0 + ); + return p; + }; + + /** Remove near-duplicate points. */ + const dedupPoints = ( + points: THREE.Vector3[], + eps = 1e-6 + ): THREE.Vector3[] => { + const out: THREE.Vector3[] = []; + for (const p of points) { + const exists = out.some( + (q) => Math.abs(p.x - q.x) < eps && Math.abs(p.y - q.y) < eps + ); + if (!exists) out.push(p); + } + return out; + }; + + /** + * Compute the convex polygon of the camera frustum clipped by plane Z=0. + * Returns ordered vertices (world coords, z=0). Empty array if no intersection. + */ + const clipFrustumAgainstZ0 = (camera: THREE.Camera): THREE.Vector3[] => { + camera.updateMatrixWorld(true); + + // Get the 8 corners of the camera frustum in world coordinates. + const corners = NDC_CORNERS.map((ndc) => + new THREE.Vector3(ndc[0], ndc[1], ndc[2]).unproject(camera) + ); + if (corners.length !== 8) return []; + + const hits: THREE.Vector3[] = []; + + // 1) Add vertices that already lie on the plane (z≈0). + for (const v of corners) { + if (Math.abs(v.z) < 1e-9) { + hits.push(new THREE.Vector3(v.x, v.y, 0)); + } + } + + // 2) Intersect each frustum edge with plane Z=0. + for (const [i, j] of FRUSTUM_EDGES) { + const a = corners[i], + b = corners[j]; + const p = intersectSegmentWithZ0(a, b); + if (p) hits.push(p); + } + + // Deduplicate and order. + const unique = dedupPoints(hits); + if (unique.length < 3) return []; + return sortConvexPolygon(unique); + }; + + /** + * Intersect the ray going through a normalized device coordinate (nx, ny) + * with the plane Z=0. Returns the hit point in THREE world coords (z=0) + * or null if the ray doesn't intersect the plane in front of the camera. + */ + const projectNDCToZ0 = ( + camera: THREE.Camera, + nx: number, + ny: number + ): THREE.Vector3 | null => { + if (!camera) return null; + + camera.updateMatrixWorld(true); + + const origin = new THREE.Vector3(); + const dir = new THREE.Vector3(); + const p = new THREE.Vector3(nx, ny, 0.5); + + if (camera instanceof THREE.OrthographicCamera) { + // For ortho, unproject a point on the camera plane, and use forward dir. + p.z = 0; // on the camera plane + p.unproject(camera); // gives a point on the camera plane in world coords + origin.copy(p); + camera.getWorldDirection(dir); + } else { + // Perspective: unproject a point on the frustum plane, build a ray. + p.unproject(camera); + origin.copy(camera.position); + dir.copy(p).sub(origin).normalize(); + } + + const dz = dir.z; + if (Math.abs(dz) < 1e-8) return null; // parallel + const t = -origin.z / dz; + if (t <= 0) return null; // behind the camera => not visible + + return origin.addScaledVector(dir, t).setZ(0); + }; + /** * The renderer for a gdjs.Layer using Pixi.js. */ @@ -47,6 +219,7 @@ namespace gdjs { private _threePlaneGeometry: THREE.PlaneGeometry | null = null; private _threePlaneMaterial: THREE.ShaderMaterial | null = null; private _threePlaneMesh: THREE.Mesh | null = null; + private _threePlaneMeshDebugOutline: THREE.LineSegments | null = null; /** * Pixi doesn't sort children with zIndex == 0. @@ -99,6 +272,9 @@ namespace gdjs { // The layer is now fully initialized. Adapt the 3D camera position // (which we could not do before in `_setup3DRendering`). this._update3DCameraAspectAndPosition(); + + // Uncomment to show the outline of the 2D rendering plane. + // this.show2DRenderingPlaneDebugOutline(true); } onGameResolutionResized() { @@ -134,6 +310,10 @@ namespace gdjs { return this._threeScene; } + getThreeGroup(): THREE.Group | null { + return this._threeGroup; + } + getThreeCamera(): | THREE.PerspectiveCamera | THREE.OrthographicCamera @@ -285,6 +465,10 @@ namespace gdjs { 'Tried to setup PixiJS plane for 2D rendering in 3D for a layer that is already set up.' ); + this.set2DPlaneMaxDrawingDistance( + this._layer.getInitialCamera2DPlaneMaxDrawingDistance() + ); + // If we have both 2D and 3D objects to be rendered, create a render texture that PixiJS will use // to render, and that will be projected on a plane by Three.js this._createPixiRenderTexture(pixiRenderer); @@ -388,30 +572,298 @@ namespace gdjs { } /** - * Update the position of the PIXI container. To be called after each change - * made to position, zoom or rotation of the camera. + * Enable or disable the drawing of an outline of the 2D rendering plane. + * Useful to visually see where the 2D rendering is done in the 3D world. */ - updatePosition(): void { - const angle = -gdjs.toRad(this._layer.getCameraRotation()); - const zoomFactor = this._layer.getCameraZoom(); - this._pixiContainer.rotation = angle; - this._pixiContainer.scale.x = zoomFactor; - this._pixiContainer.scale.y = zoomFactor; - const cosValue = Math.cos(angle); - const sinValue = Math.sin(angle); - const centerX = - this._layer.getCameraX() * zoomFactor * cosValue - - this._layer.getCameraY() * zoomFactor * sinValue; - const centerY = - this._layer.getCameraX() * zoomFactor * sinValue + - this._layer.getCameraY() * zoomFactor * cosValue; - this._pixiContainer.position.x = this._layer.getWidth() / 2 - centerX; - this._pixiContainer.position.y = this._layer.getHeight() / 2 - centerY; + show2DRenderingPlaneDebugOutline(enable: boolean) { + if (!this._threePlaneMesh) return; + if (enable && !this._threePlaneMeshDebugOutline) { + // Add rectangle outline around the plane. + const edges = new THREE.EdgesGeometry(this._threePlaneGeometry); + const lineMaterial = new THREE.LineBasicMaterial({ + color: 0xff0000, + }); + this._threePlaneMeshDebugOutline = new THREE.LineSegments( + edges, + lineMaterial + ); + // Attach the outline to the plane so it follows position/scale/rotation. + this._threePlaneMesh.add(this._threePlaneMeshDebugOutline); + } + if (!enable && this._threePlaneMeshDebugOutline) { + this._threePlaneMesh.remove(this._threePlaneMeshDebugOutline); + this._threePlaneMeshDebugOutline = null; + } + } + + /** Maximum size of the 2D plane, in pixels. */ + private _2DPlaneMaxDrawingDistance: number = 5000; + /** Tilt degrees below which the 2D plane is not clamped. */ + private _2DPlaneClampFreeTiltDeg: number = 0.1; + /** Tilt degrees below which the 2D plane is fully clamped. */ + private _2DPlaneClampHardTiltDeg: number = 6; + private _2DPlaneClampRampPower: number = 1.5; // 1 = linear, >1 = smoother + + /** + * Set the maximum "drawing distance", in pixels, of the 2D when in the 3D world. + * This corresponds to the "height" of the 2D plane. + * Used when the 3D camera is tilted on the X or Y axis (instead of looking down the Z axis, + * as it's done by default for 2D games). + * This is useful to avoid the 2D plane being too big when the camera is tilted. + */ + set2DPlaneMaxDrawingDistance(h: number) { + this._2DPlaneMaxDrawingDistance = Math.max(0, h); + } + + /** + * Set the tilt degrees below which the 2D plane is not clamped. + */ + set2DPlaneClampFreeTiltDegrees(d: number) { + this._2DPlaneClampFreeTiltDeg = Math.max(0, d); + } + + /** + * Set the tilt degrees below which the 2D plane is clamped (see `set2DPlaneMaxDrawingDistance`). + */ + set2DPlaneClampHardTiltDegrees(d: number) { + this._2DPlaneClampHardTiltDeg = Math.max(0, d); + } + + /** + * Set the ramp power of the 2D plane clamping (see `set2DPlaneMaxDrawingDistance`). Used + * for smoother transition between clamped and unclamped. + */ + set2DPlaneClampRampPower(p: number) { + this._2DPlaneClampRampPower = Math.max(0.1, p); + } + + /** + * Get the size of the 2D plane, in the world coordinates. + */ + private _get2DPlaneSize(): [number, number] { + if (!this._threeCamera) return [0, 0]; + + // Compute the intersection of the frustrum of the camera on the Z=0 plane. + // In theory, that's where the entire 2D rendering should be displayed. + const poly = clipFrustumAgainstZ0(this._threeCamera); + + if (poly.length === 0) { + // No intersection at all: Z=0 not in view. + return [0, 0]; + } + + // Compute the axis-aligned bounds on Z=0 (world units) of the polygon, + // so we can compute the size of the plane doing the 2D rendering. + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const p of poly) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y < minY) minY = p.y; + if (p.y > maxY) maxY = p.y; + } + let boxW = Math.max(1e-8, maxX - minX); + let boxH = Math.max(1e-8, maxY - minY); + + // Keep 2D layer aspect ratio (so texture isn't stretched). + const targetAspect = this._layer.getWidth() / this._layer.getHeight(); + const boxAspect = boxW / boxH; + if (boxAspect < targetAspect) { + boxW = targetAspect * boxH; + } else { + boxH = boxW / targetAspect; + } + + // Decide if we should cap based on camera tilt (X/Y) --- + const forward = new THREE.Vector3(); + this._threeCamera.getWorldDirection(forward); + // |forward.z| ≈ 1 -> no tilt (look mostly perpendicular to Z=0). + // |forward.z| ≈ 0 -> grazing the horizon (strong tilt). + + const freeCos = Math.cos( + THREE.MathUtils.degToRad(this._2DPlaneClampFreeTiltDeg) + ); + const hardCos = Math.cos( + THREE.MathUtils.degToRad(this._2DPlaneClampHardTiltDeg) + ); + const tiltCos = Math.abs(forward.z); + + // Map tiltCos ∈ [hardCos, freeCos] to w ∈ [1, 0] + let w = 0; + if (tiltCos <= hardCos) + w = 1; // fully clamped + else if (tiltCos >= freeCos) + w = 0; // no clamp + else w = (freeCos - tiltCos) / (freeCos - hardCos); + + // Ease it + w = Math.pow(w, this._2DPlaneClampRampPower); + + // Interpolate Infinity→base via 1/w (bounded): + const BIG = 1e12; // “practically infinite” + const denom = Math.max(w, 1e-6); + const effectiveMaxH = Math.min( + BIG, + this._2DPlaneMaxDrawingDistance / denom + ); + + // Apply the max height. + if (effectiveMaxH < BIG) { + const clampedH = Math.max(1e-8, Math.min(boxH, effectiveMaxH)); + if (clampedH !== boxH) { + boxH = clampedH; + boxW = targetAspect * boxH; // keep aspect + } + } + + return [boxW, boxH]; + } + + private _get2DPlanePosition(boxH: number): [number, number] { + if (!this._threeCamera) return [0, 0]; + + // Choose the plane position (anchor to bottom of screen, heading-invariant) --- + const bottomLeft = projectNDCToZ0(this._threeCamera, -1, -1); + const bottomRight = projectNDCToZ0(this._threeCamera, +1, -1); + + let cx: number, cy: number; + + if (bottomLeft && bottomRight) { + // Midpoint of the bottom-of-screen segment on Z=0: + const mx = 0.5 * (bottomLeft.x + bottomRight.x); + const my = 0.5 * (bottomLeft.y + bottomRight.y); + + // Tangent along the bottom line (unit): + let dx = bottomRight.x - bottomLeft.x; + let dy = bottomRight.y - bottomLeft.y; + const len = Math.hypot(dx, dy) || 1; + dx /= len; + dy /= len; + + // Inward normal n = +90° rotation of d in XY plane: + // d = (dx, dy) -> n = (-dy, dx) + let nx = -dy; + let ny = dx; + + // Ensure n points "into the screen": + const midIn = projectNDCToZ0(this._threeCamera, 0, -0.5); + if (midIn) { + const vx = midIn.x - mx; + const vy = midIn.y - my; + if (vx * nx + vy * ny < 0) { + nx = -nx; + ny = -ny; + } + } + + // Place the plane so its bottom edge lies on the bottom-of-screen line: + cx = mx + nx * (boxH * 0.5); + cy = my + ny * (boxH * 0.5); + } else { + // Fallback to the camera center projected on Z=0 if bottom line not visible: + const centerRay = projectNDCToZ0(this._threeCamera, 0, 0); + if (centerRay) { + cx = centerRay.x; + cy = centerRay.y; + } else { + // Fallback to the camera position if the center ray is not visible: + cx = this._threeCamera.position.x; + cy = this._threeCamera.position.y; + } + } + return [cx, cy]; + } + + updatePosition(): void { + // Update the 3D camera position and rotation. + if (this._threeCamera) { + const angle = -gdjs.toRad(this._layer.getCameraRotation()); + this._threeCamera.position.x = this._layer.getCameraX(); + this._threeCamera.position.y = -this._layer.getCameraY(); // scene is mirrored on Y + this._threeCamera.rotation.z = angle; + + if (this._threeCamera instanceof THREE.OrthographicCamera) { + this._threeCamera.zoom = this._layer.getCameraZoom(); + this._threeCamera.updateProjectionMatrix(); + this._threeCamera.position.z = this._layer.getCameraZ(null); + } else { + this._threeCamera.position.z = this._layer.getCameraZ( + this._threeCamera.fov + ); + } + } + + let effectivePixiZoom = 1; + const angle = -gdjs.toRad(this._layer.getCameraRotation()); + const angleCosValue = Math.cos(angle); + const angleSinValue = Math.sin(angle); + + // Update the 2D plane in the 3D world position, size and rotation, + // and update the 2D Pixi container position, size and rotation. + if (this._threeCamera && this._threePlaneMesh) { + const [boxW, boxH] = this._get2DPlaneSize(); + + if (boxW === 0 || boxH === 0) { + // No size means the 2D plane is not visible. + this._threePlaneMesh.visible = false; + } else { + this._threePlaneMesh.visible = true; + + const [cx, cy] = this._get2DPlanePosition(boxH); + + // Update the 2D plane size, position and rotation (so 2D remains upright). + // Plane size (geometry is 1×1). + this._threePlaneMesh.scale.set(boxW, boxH, 1); + this._threePlaneMesh.position.set(cx, -cy, 0); + this._threePlaneMesh.rotation.set(0, 0, -angle); + + // Update the 2D Pixi container size and rotation to match the "zoom" (which comes from the 2D plane size) + // rotation and position. + effectivePixiZoom = this._layer.getWidth() / boxW; // == height/boxH + this._pixiContainer.scale.set(effectivePixiZoom, effectivePixiZoom); + this._pixiContainer.rotation = angle; + + const followX = cx; + const followY = -cy; + const centerX2d = + followX * effectivePixiZoom * angleCosValue - + followY * effectivePixiZoom * angleSinValue; + const centerY2d = + followX * effectivePixiZoom * angleSinValue + + followY * effectivePixiZoom * angleCosValue; + this._pixiContainer.position.x = + this._layer.getWidth() / 2 - centerX2d; + this._pixiContainer.position.y = + this._layer.getHeight() / 2 - centerY2d; + } + } + + // 2D only (no 3D rendering and so no 2D plane in the 3D world): + // Update the 2D Pixi container position, size and rotation. + if (!this._threeCamera || !this._threePlaneMesh) { + effectivePixiZoom = this._layer.getCameraZoom(); + this._pixiContainer.rotation = angle; + this._pixiContainer.scale.x = effectivePixiZoom; + this._pixiContainer.scale.y = effectivePixiZoom; + const centerX = + this._layer.getCameraX() * effectivePixiZoom * angleCosValue - + this._layer.getCameraY() * effectivePixiZoom * angleSinValue; + const centerY = + this._layer.getCameraX() * effectivePixiZoom * angleSinValue + + this._layer.getCameraY() * effectivePixiZoom * angleCosValue; + this._pixiContainer.position.x = this._layer.getWidth() / 2 - centerX; + this._pixiContainer.position.y = this._layer.getHeight() / 2 - centerY; + } + + // Pixel rounding for the Pixi rendering (be it for 2D only + // or for the 2D rendering shown in the 2D plane in the 3D world). if ( this._layer.getRuntimeScene().getGame().getPixelsRounding() && - (cosValue === 0 || sinValue === 0) && - Number.isInteger(zoomFactor) + (angleCosValue === 0 || angleSinValue === 0) && + Number.isInteger(effectivePixiZoom) ) { // Camera rounding is important for pixel perfect games. // Otherwise, the camera position fractional part is added to @@ -467,39 +919,12 @@ namespace gdjs { ); } } - - if (this._threeCamera) { - // TODO (3D) - improvement: handle camera rounding like down for PixiJS? - this._threeCamera.position.x = this._layer.getCameraX(); - this._threeCamera.position.y = -this._layer.getCameraY(); // Inverted because the scene is mirrored on Y axis. - this._threeCamera.rotation.z = angle; - - if (this._threeCamera instanceof THREE.OrthographicCamera) { - this._threeCamera.zoom = this._layer.getCameraZoom(); - this._threeCamera.updateProjectionMatrix(); - this._threeCamera.position.z = this._layer.getCameraZ(null); - } else { - this._threeCamera.position.z = this._layer.getCameraZ( - this._threeCamera.fov - ); - } - - if (this._threePlaneMesh) { - // Adapt the plane size so that it covers the whole screen. - this._threePlaneMesh.scale.x = this._layer.getWidth() / zoomFactor; - this._threePlaneMesh.scale.y = this._layer.getHeight() / zoomFactor; - - // Adapt the plane position so that it's always displayed on the whole screen. - this._threePlaneMesh.position.x = this._threeCamera.position.x; - this._threePlaneMesh.position.y = -this._threeCamera.position.y; // Inverted because the scene is mirrored on Y axis. - this._threePlaneMesh.rotation.z = -angle; - } - } } updateResolution() { if (this._threeEffectComposer) { const game = this._layer.getRuntimeScene().getGame(); + this._threeEffectComposer.setPixelRatio(window.devicePixelRatio); this._threeEffectComposer.setSize( game.getGameResolutionWidth(), game.getGameResolutionHeight() diff --git a/GDJS/Runtime/pixi-renderers/pixi-bitmapfont-manager.ts b/GDJS/Runtime/pixi-renderers/pixi-bitmapfont-manager.ts index 066302957b..26e04ac28c 100644 --- a/GDJS/Runtime/pixi-renderers/pixi-bitmapfont-manager.ts +++ b/GDJS/Runtime/pixi-renderers/pixi-bitmapfont-manager.ts @@ -316,7 +316,7 @@ namespace gdjs { } unloadResource(resourceData: ResourceData): void { - const loadedFont = this._loadedFontsData.get(resourceData); + const loadedFont = this._loadedFontsData.getFromName(resourceData.name); if (loadedFont) { this._loadedFontsData.delete(resourceData); } diff --git a/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts b/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts index 4bdb1ddc16..8e2d0975c8 100644 --- a/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts +++ b/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts @@ -103,6 +103,10 @@ namespace gdjs { if (!existingTexture) { return this._invalidTexture; } + if (existingTexture.destroyed) { + logger.error('Texture for ' + resourceName + ' is not valid anymore.'); + return this._invalidTexture; + } if (!existingTexture.valid) { logger.error( 'Texture for ' + diff --git a/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts index c8f7d8f604..3c84ceeb69 100644 --- a/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/runtimegame-pixi-renderer.ts @@ -13,6 +13,19 @@ namespace gdjs { 40, // ArrowDown ]; + // Workaround for a macOS issue where "keyup" is not triggered when a key + // is released while meta key is pressed. + const keysPressedWithMetaPressedByCode = new Map< + string, + { keyCode: number; location: number } + >(); + + const isMacLike = + typeof navigator !== 'undefined' && + navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i) + ? true + : false; + /** * The renderer for a gdjs.RuntimeGame using Pixi.js. */ @@ -101,6 +114,7 @@ namespace gdjs { this._threeRenderer.shadowMap.type = THREE.PCFSoftShadowMap; this._threeRenderer.useLegacyLights = true; this._threeRenderer.autoClear = false; + this._threeRenderer.pixelRatio = window.devicePixelRatio; this._threeRenderer.setSize( this._game.getGameResolutionWidth(), this._game.getGameResolutionHeight() @@ -643,7 +657,7 @@ namespace gdjs { return false; return true; }; - document.onkeydown = function (e) { + document.onkeydown = (e) => { if (isFocusingDomElement()) { // Bail out if the game canvas is not focused. For example, // an `` element can be focused, and needs to receive @@ -651,12 +665,32 @@ namespace gdjs { return; } + // See reason for this workaround in the "keyup" event handler. + if (isMacLike) { + if (e.code !== 'MetaLeft' && e.code !== 'MetaRight') { + if (e.metaKey) { + keysPressedWithMetaPressedByCode.set(e.code, { + keyCode: e.keyCode, + location: e.location, + }); + } else { + keysPressedWithMetaPressedByCode.delete(e.code); + } + } + } + if (defaultPreventedKeyCodes.includes(e.keyCode)) { // Some keys are "default prevented" to avoid scrolling when the game // is integrated in a page as an iframe. e.preventDefault(); } + if (this._game.isInGameEdition()) { + // When in in-game edition, prevent all the keys to have their default behavior + // so that the shortcuts are all handled by the editor (apart from OS-level shortcuts). + e.preventDefault(); + } + if (e.repeat) { // If `repeat` is true, this is not the first press of the key. // We only communicate the changes of states ("first" key down, key up) @@ -667,7 +701,7 @@ namespace gdjs { manager.onKeyPressed(e.keyCode, e.location); }; - document.onkeyup = function (e) { + document.onkeyup = (e) => { if (isFocusingDomElement()) { // Bail out if the game canvas is not focused. For example, // an `` element can be focused, and needs to receive @@ -675,6 +709,29 @@ namespace gdjs { return; } + if (isMacLike) { + if (e.code === 'MetaLeft' || e.code === 'MetaRight') { + // Meta key is released. On macOS, a key pressed in combination with meta key, and + // which has been released while meta is pressed, will not trigger a "keyup" event. + // This means the key would be considered as "stuck" from the game's perspective + // it would never be released unless it's pressed and released again (without meta). + // Out of caution, we simulate a release of the key that were pressed with meta key. + for (const { + location, + keyCode, + } of keysPressedWithMetaPressedByCode.values()) { + manager.onKeyReleased(keyCode, location); + } + keysPressedWithMetaPressedByCode.clear(); + } + } + + if (this._game.isInGameEdition()) { + // When in in-game edition, prevent all the keys to have their default behavior + // so that the shortcuts are all handled by the editor (apart from OS-level shortcuts). + e.preventDefault(); + } + if (defaultPreventedKeyCodes.includes(e.keyCode)) { // Some keys are "default prevented" to avoid scrolling when the game // is integrated in a page as an iframe. @@ -761,7 +818,7 @@ namespace gdjs { }; // @ts-ignore canvas.onwheel = function (event) { - manager.onMouseWheel(-event.deltaY); + manager.onMouseWheel(-event.deltaY, event.deltaX, event.deltaZ); }; // Touches: @@ -784,6 +841,7 @@ namespace gdjs { touch.pageY ); manager.onTouchMove(touch.identifier, pos[0], pos[1]); + manager.onTouchMove(touch.identifier, pos[0], pos[1]); // This works because touch events are sent // when they continue outside of the canvas. if (manager.isSimulatingMouseWithTouch()) { diff --git a/GDJS/Runtime/pixi-renderers/runtimescene-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/runtimescene-pixi-renderer.ts index d00f566360..221682e79f 100644 --- a/GDJS/Runtime/pixi-renderers/runtimescene-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/runtimescene-pixi-renderer.ts @@ -115,9 +115,10 @@ namespace gdjs { const runtimeLayerRenderingType = runtimeLayer.getRenderingType(); const layerHas3DObjectsToRender = runtimeLayerRenderer.has3DObjects(); if ( - runtimeLayerRenderingType === + !this._runtimeScene.getGame().isInGameEdition() && + (runtimeLayerRenderingType === gdjs.RuntimeLayerRenderingType.TWO_D || - !layerHas3DObjectsToRender + !layerHas3DObjectsToRender) ) { // Render a layer with 2D rendering (PixiJS) only if layer is configured as is // or if there is no 3D object to render. diff --git a/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts index dd0a3b7ee1..49f62777a3 100644 --- a/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/spriteruntimeobject-pixi-renderer.ts @@ -57,35 +57,50 @@ namespace gdjs { */ _updatePIXISprite() { const animationFrame = this._object._animator.getCurrentFrame(); - if (animationFrame !== null) { - const scaleX = this._object._scaleX * this._object._preScale; - const scaleY = this._object._scaleY * this._object._preScale; - this._sprite.anchor.x = - animationFrame.center.x / this._sprite.texture.frame.width; - this._sprite.anchor.y = - animationFrame.center.y / this._sprite.texture.frame.height; - this._sprite.position.x = - this._object.x + - (animationFrame.center.x - animationFrame.origin.x) * - Math.abs(scaleX); - this._sprite.position.y = - this._object.y + - (animationFrame.center.y - animationFrame.origin.y) * - Math.abs(scaleY); - this._sprite.rotation = gdjs.toRad(this._object.angle); - this._sprite.visible = !this._object.hidden; - this._sprite.blendMode = this._object._blendMode; - this._sprite.alpha = this._object.opacity / 255; - this._sprite.scale.x = scaleX; - this._sprite.scale.y = scaleY; - this._cachedWidth = Math.abs(this._sprite.width); - this._cachedHeight = Math.abs(this._sprite.height); - } else { + if ( + animationFrame === null && + !this._object.getInstanceContainer().getGame().isInGameEdition() + ) { + // Sprites without any animation are hidden at runtime. this._sprite.visible = false; this._sprite.alpha = 0; this._cachedWidth = 0; this._cachedHeight = 0; + this._spriteDirty = false; + return; } + let centerX = 0; + let centerY = 0; + let originX = 0; + let originY = 0; + if (animationFrame) { + centerX = animationFrame.center.x; + centerY = animationFrame.center.y; + originX = animationFrame.origin.x; + originY = animationFrame.origin.y; + } else { + centerX = this._sprite.texture.frame.width / 2; + centerY = this._sprite.texture.frame.height / 2; + originX = 0; + originY = 0; + } + const scaleX = this._object._scaleX * this._object._preScale; + const scaleY = this._object._scaleY * this._object._preScale; + this._sprite.anchor.x = centerX / this._sprite.texture.frame.width; + this._sprite.anchor.y = centerY / this._sprite.texture.frame.height; + this._sprite.position.x = + this._object.x + (centerX - originX) * Math.abs(scaleX); + this._sprite.position.y = + this._object.y + (centerY - originY) * Math.abs(scaleY); + this._sprite.rotation = gdjs.toRad(this._object.angle); + this._sprite.visible = !this._object.hidden; + this._sprite.blendMode = this._object._blendMode; + this._sprite.alpha = this._object.opacity / 255; + this._sprite.scale.x = scaleX; + this._sprite.scale.y = scaleY; + this._cachedWidth = Math.abs(this._sprite.width); + this._cachedHeight = Math.abs(this._sprite.height); + this._spriteDirty = false; } @@ -112,19 +127,25 @@ namespace gdjs { updateX(): void { const animationFrame = - this._object._animator.getCurrentFrame() as SpriteAnimationFrame; + this._object._animator.getCurrentFrame() as SpriteAnimationFrame | null; + const originToCenter = animationFrame + ? animationFrame.center.x - animationFrame.origin.x + : this.getUnscaledWidth() / 2; this._sprite.position.x = this._object.x + - (animationFrame.center.x - animationFrame.origin.x) * + originToCenter * Math.abs(this._object._scaleX * this._object._preScale); } updateY(): void { const animationFrame = - this._object._animator.getCurrentFrame() as SpriteAnimationFrame; + this._object._animator.getCurrentFrame() as SpriteAnimationFrame | null; + const originToCenter = animationFrame + ? animationFrame.center.y - animationFrame.origin.y + : this.getUnscaledHeight() / 2; this._sprite.position.y = this._object.y + - (animationFrame.center.y - animationFrame.origin.y) * + originToCenter * Math.abs(this._object._scaleY * this._object._preScale); } diff --git a/GDJS/Runtime/runtimebehavior.ts b/GDJS/Runtime/runtimebehavior.ts index 0638fcf367..7013533c00 100644 --- a/GDJS/Runtime/runtimebehavior.ts +++ b/GDJS/Runtime/runtimebehavior.ts @@ -38,7 +38,7 @@ namespace gdjs { name: string; type: string; _nameId: integer; - _activated: boolean = true; + _activated: boolean; // When synchronised over the network, a behavior is always owned by the player owning the object, // and always synced. If set to false, the behavior properties will not be synced to others. @@ -57,6 +57,10 @@ namespace gdjs { this.name = behaviorData.name || ''; this.type = behaviorData.type || ''; this._nameId = gdjs.RuntimeObject.getNameIdentifier(this.name); + const game = instanceContainer.getGame(); + this._activated = + !game.isInGameEdition() || + !!game.isBehaviorActivatedByDefaultInEditor(this.type); } /** diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index 9778d5069f..47848c6bcb 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -41,18 +41,73 @@ 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; + eventsBasedObjectType: string | null; + eventsBasedObjectVariantName: string | null; + editorId: string | null; + editorCamera3D?: EditorCameraState; + }; + + /** + * 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, + eventsBasedObjectType: parsedRuntimeGameStatus.eventsBasedObjectType, + eventsBasedObjectVariantName: + parsedRuntimeGameStatus.eventsBasedObjectVariantName, + editorId: parsedRuntimeGameStatus.editorId, + editorCamera3D: parsedRuntimeGameStatus.editorCamera3D, + }; + } 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; + + inGameEditorSettings?: InGameEditorSettings; + /** Script files, used for hot-reloading. */ scriptFiles?: Array; - /** if true, export is a partial preview without events. */ - projectDataOnlyExport?: boolean; + /** if true, export is a partial preview without reloading libraries. */ + shouldReloadLibraries?: boolean; + /** if true, export is a partial preview without generating events. */ + shouldGenerateScenesEventsCode?: boolean; + /** if true, preview is launched from GDevelop native mobile app. */ nativeMobileApp?: boolean; /** The address of the debugger server, to reach out using WebSocket. */ @@ -139,7 +194,13 @@ namespace gdjs { _gameResolutionHeight: integer; _originalWidth: float; _originalHeight: float; - _resizeMode: 'adaptWidth' | 'adaptHeight' | string; + _resizeMode: + | '' + | 'scaleOuter' + | 'adaptWidth' + | 'adaptHeight' + | 'native' + | string; _adaptGameResolutionAtRuntime: boolean; _scaleMode: 'linear' | 'nearest'; _pixelsRounding: boolean; @@ -171,12 +232,8 @@ namespace gdjs { _hasJustResumed: boolean = false; //Inputs : - _inputManager: InputManager; + private _inputManager: InputManager; - /** - * Allow to specify an external layout to insert in the first scene. - */ - _injectExternalLayout: any; _options: RuntimeGameOptions; /** @@ -194,6 +251,7 @@ namespace gdjs { _sessionMetricsInitialized: boolean = false; _disableMetrics: boolean = false; _isPreview: boolean; + _isInGameEdition: boolean; /** * The capture manager, used to manage captures (screenshots, videos, etc...). @@ -203,12 +261,27 @@ namespace gdjs { /** True if the RuntimeGame has been disposed and should not be used anymore. */ _wasDisposed: boolean = false; + _inGameEditor: InGameEditor | null; + /** * @param data The object (usually stored in data.json) containing the full project data * @param options The game options */ 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, @@ -237,7 +310,16 @@ namespace gdjs { getGlobalResourceNames(data), data.layouts ); - + this._inGameEditor = this._isInGameEdition + ? new gdjs.InGameEditor( + this, + data, + this._options.inGameEditorSettings || null + ) + : null; + this._debuggerClient = gdjs.DebuggerClient + ? new gdjs.DebuggerClient(this) + : null; this._effectsManager = new gdjs.EffectsManager(); this._maxFPS = this._data.properties.maxFPS; this._minFPS = this._data.properties.minFPS; @@ -265,17 +347,12 @@ 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; this._captureManager = gdjs.CaptureManager ? new gdjs.CaptureManager( this._renderer, this._options.captureOptions || {} ) : null; - this._isPreview = this._options.isPreview || false; this._sessionId = null; this._playerId = null; @@ -311,6 +388,9 @@ namespace gdjs { * @param projectData The object (usually stored in data.json) containing the full project data */ setProjectData(projectData: ProjectData): void { + if (this._inGameEditor) { + this._inGameEditor.onProjectDataChange(projectData); + } this._data = projectData; this._updateSceneAndExtensionsData(); this._resourcesLoader.setResources( @@ -485,6 +565,55 @@ namespace gdjs { return eventsBasedObjectData; } + getEventsBasedObjectVariantData( + type: string, + variantName: string + ): EventsBasedObjectVariantData | null { + const eventsBasedObjectData = this.getEventsBasedObjectData(type); + if (!eventsBasedObjectData) { + return null; + } + return gdjs.RuntimeGame._getEventsBasedObjectVariantData( + eventsBasedObjectData, + variantName + ); + } + + static _getEventsBasedObjectVariantData( + eventsBasedObjectData: EventsBasedObjectData, + variantName: string + ): EventsBasedObjectVariantData { + if (!eventsBasedObjectData.defaultVariant) { + eventsBasedObjectData.defaultVariant = { + ...eventsBasedObjectData, + name: '', + }; + } + // Legacy events-based objects don't have any instance in their default + // variant since there wasn't a graphical editor at the time. + // In this case, the editor doesn't allow to choose a variant, but a + // variant may have stayed after a user rolled back the extension. + // This variant must be ignored to match what the editor shows. + const isForcedToOverrideEventsBasedObjectChildrenConfiguration = + eventsBasedObjectData.defaultVariant.instances.length == 0; + if (isForcedToOverrideEventsBasedObjectChildrenConfiguration) { + return eventsBasedObjectData.defaultVariant; + } + let usedVariantData: EventsBasedObjectVariantData = + eventsBasedObjectData.defaultVariant; + for ( + let variantIndex = 0; + variantIndex < eventsBasedObjectData.variants.length; + variantIndex++ + ) { + const variantData = eventsBasedObjectData.variants[variantIndex]; + if (variantData.name === variantName) { + usedVariantData = variantData; + } + } + return usedVariantData; + } + /** * Get the data associated to a scene. * @@ -523,6 +652,22 @@ namespace gdjs { return false; } + /** + * Get the data associated to a scene. + * + * @param name The name of the scene. + * @return The data associated to the scene or null if not found. + */ + getSceneData(sceneName: string): LayoutData | null { + for (let i = 0, len = this._data.layouts.length; i < len; ++i) { + const sceneData = this._data.layouts[i]; + if (sceneData.name == sceneName) { + return sceneData; + } + } + return null; + } + /** * Get the data associated to an external layout. * @@ -594,7 +739,7 @@ namespace gdjs { this._gameResolutionWidth = width; this._gameResolutionHeight = height; - if (this._adaptGameResolutionAtRuntime) { + if (this._adaptGameResolutionAtRuntime || this._isInGameEdition) { if ( gdjs.RuntimeGameRenderer && gdjs.RuntimeGameRenderer.getWindowInnerWidth && @@ -606,7 +751,10 @@ namespace gdjs { gdjs.RuntimeGameRenderer.getWindowInnerHeight(); // Enlarge either the width or the eight to fill the inner window space. - if (this._resizeMode === 'adaptWidth') { + if (this._isInGameEdition) { + this._gameResolutionWidth = windowInnerWidth; + this._gameResolutionHeight = windowInnerHeight; + } else if (this._resizeMode === 'adaptWidth') { this._gameResolutionWidth = (this._gameResolutionHeight * windowInnerWidth) / windowInnerHeight; @@ -735,9 +883,9 @@ namespace gdjs { if (this._paused === enable) return; this._paused = enable; + if (this._inGameEditor) this._inGameEditor.activate(enable); if (this._debuggerClient) { - if (this._paused) this._debuggerClient.sendGamePaused(); - else this._debuggerClient.sendGameResumed(); + this._debuggerClient.sendRuntimeGameStatus(); } } @@ -914,11 +1062,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 @@ -938,10 +1091,41 @@ namespace gdjs { this._forceGameResolutionUpdate(); // Load the first scene - this._sceneStack.push( - this._getFirstSceneName(), - this._injectExternalLayout - ); + const sceneName = this._getFirstSceneName(); + const externalLayoutName = + this._options.initialRuntimeGameStatus?.injectedExternalLayoutName || + null; + if (this._inGameEditor) { + const eventsBasedObjectType = + this._options.initialRuntimeGameStatus?.eventsBasedObjectType || + null; + const eventsBasedObjectVariantName = + this._options.initialRuntimeGameStatus + ?.eventsBasedObjectVariantName || null; + const editorId = + this._options.initialRuntimeGameStatus?.editorId || null; + const editorCamera3D = + this._options.initialRuntimeGameStatus?.editorCamera3D || null; + this._inGameEditor.switchToSceneOrVariant( + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + editorCamera3D + ); + } else { + if (sceneName) { + this.getSceneStack().replace({ + sceneName, + externalLayoutName: + externalLayoutName === null ? undefined : externalLayoutName, + skipCreatingInstances: !!externalLayoutName, + clear: true, + }); + } + } + this._watermark.displayAtStartup(); //Uncomment to profile the first x frames of the game. @@ -967,15 +1151,33 @@ namespace gdjs { } // The standard game loop + let lastFrameSceneName: string | null = null; let accumulatedElapsedTime = 0; this._hasJustResumed = false; this._renderer.startGameLoop((lastCallElapsedTime) => { try { - if (this._paused) { - return true; + // Watch the scene name to automatically update debugger when a scene is changed. + if (this._debuggerClient) { + const currentScene = ( + this._inGameEditor || this.getSceneStack() + ).getCurrentScene(); + if ( + currentScene && + currentScene.getName() !== lastFrameSceneName + ) { + lastFrameSceneName = currentScene.getName(); + this._debuggerClient.sendRuntimeGameStatus(); + } } - // Skip the frame if we rendering frames too fast + // If the game is edited, update the target framerate according to interactions. + // Do it now (before frame skip), so that if a user interaction happens + // we don't wait for a frame to pass at the current, probably very slow framerate. + if (this._paused && this._inGameEditor) { + this._inGameEditor.updateTargetFramerate(lastCallElapsedTime); + } + + // Skip the frame if we rendering frames too fast. accumulatedElapsedTime += lastCallElapsedTime; if ( this._maxFPS > 0 && @@ -992,17 +1194,36 @@ namespace gdjs { // Manage resize events. if (this._notifyScenesForGameResolutionResize) { - this._sceneStack.onGameResolutionResized(); + if (this._inGameEditor) { + this._inGameEditor.onGameResolutionResized(); + } else { + this._sceneStack.onGameResolutionResized(); + } this._notifyScenesForGameResolutionResize = false; } - // Render and step the scene. - if (this._sceneStack.step(elapsedTime)) { - this.getInputManager().onFrameEnded(); + // Render and possibly step the game. + if (this._paused) { + if (this._inGameEditor) { + // The game is paused for edition: the in-game editor runs and render + // the scene. + this._inGameEditor.updateAndRender(); + } else { + // The game is paused (for debugging): the rendering of the scene is done, + // but the game logic is not executed (no full "step"). + this._sceneStack.renderWithoutStep(); + } + } else { + // The game is not paused (and so, not edited): both the rendering + // and game logic (a full "step") is executed. + if (!this._sceneStack.step(elapsedTime)) { + return false; // Return if game asked to be stopped. + } this._hasJustResumed = false; - return true; } - return false; + + this.getInputManager().onFrameEnded(); + return true; } catch (e) { if (this._debuggerClient) this._debuggerClient.onUncaughtException(e); @@ -1323,6 +1544,43 @@ 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; + } + + /** + * Return in-game editor. + */ + getInGameEditor(): InGameEditor | null { + return this._inGameEditor; + } + + isBehaviorActivatedByDefaultInEditor(type: string): boolean { + return this._data.activatedByDefaultInEditorBehaviors + ? this._data.activatedByDefaultInEditorBehaviors.includes(type) + : false; + } + + /** + * Set the maximum FPS of the game. + * @param maximumFps The maximum FPS. + */ + setMaximumFps(maximumFps: integer) { + this._maxFPS = maximumFps; + } + /** * Check if the game should call GDevelop development APIs or not. * diff --git a/GDJS/Runtime/runtimeobject.ts b/GDJS/Runtime/runtimeobject.ts index dd3fdc6712..273bb0d385 100644 --- a/GDJS/Runtime/runtimeobject.ts +++ b/GDJS/Runtime/runtimeobject.ts @@ -1437,6 +1437,22 @@ namespace gdjs { return this.hidden; } + /** + * Return the width of the object before any custom size is applied. + * @return The width of the object + */ + getOriginalWidth(): float { + return this.getWidth(); + } + + /** + * Return the width of the object before any custom size is applied. + * @return The width of the object + */ + getOriginalHeight(): float { + return this.getHeight(); + } + /** * Set the width of the object, if applicable. * @param width The new width in pixels. diff --git a/GDJS/Runtime/runtimescene.ts b/GDJS/Runtime/runtimescene.ts index 246033d39a..d742bf6c92 100644 --- a/GDJS/Runtime/runtimescene.ts +++ b/GDJS/Runtime/runtimescene.ts @@ -65,7 +65,7 @@ namespace gdjs { * @param runtimeGame The game associated to this scene. */ constructor(runtimeGame: gdjs.RuntimeGame) { - super(); + super(runtimeGame); this._runtimeGame = runtimeGame; this._variables = new gdjs.VariablesContainer(); this._variablesByExtensionName = new Map< @@ -128,7 +128,8 @@ namespace gdjs { /** * Load the runtime scene from the given scene. - * @param sceneAndExtensionsData 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( @@ -136,6 +137,7 @@ namespace gdjs { options?: { excludedObjectNames?: Set; skipStoppingSoundsOnStartup?: boolean; + skipCreatingInstances?: boolean; } ) { if (!sceneAndExtensionsData) { @@ -196,17 +198,19 @@ namespace gdjs { } // Create initial instances of objects. - this.createObjectsFrom( - sceneData.instances, - 0, - 0, - 0, - /*trackByPersistentUuid=*/ - true, - { - excludedObjectNames: options?.excludedObjectNames, - } - ); + if (!options || !options.skipCreatingInstances) { + this.createObjectsFrom( + sceneData.instances, + 0, + 0, + 0, + /*trackByPersistentUuid=*/ + true, + { + excludedObjectNames: options?.excludedObjectNames, + } + ); + } // Set up the default z order (for objects created from events) this._setLayerDefaultZOrders(); @@ -379,7 +383,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. @@ -439,6 +443,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)'); } @@ -468,21 +487,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(); } diff --git a/GDJS/Runtime/scenestack.ts b/GDJS/Runtime/scenestack.ts index 85a0f20c60..97c9cba180 100644 --- a/GDJS/Runtime/scenestack.ts +++ b/GDJS/Runtime/scenestack.ts @@ -9,6 +9,7 @@ namespace gdjs { interface PushSceneOptions { sceneName: string; externalLayoutName?: string; + skipCreatingInstances?: boolean; getExcludedObjectNames?: (runtimeScene: RuntimeScene) => Set; skipStoppingSoundsOnStartup?: boolean; } @@ -97,10 +98,10 @@ namespace gdjs { renderWithoutStep(): boolean { this._throwIfDisposed(); - if (this._stack.length === 0) { + const currentScene = this.getCurrentScene(); + if (!currentScene) { return false; } - const currentScene = this._stack[this._stack.length - 1]; currentScene.render(); return true; } @@ -158,6 +159,8 @@ namespace gdjs { typeof options === 'string' ? false : options.skipStoppingSoundsOnStartup; + const skipCreatingInstances = + typeof options === 'string' ? false : options.skipCreatingInstances; const externalLayoutName = deprecatedExternalLayoutName || (typeof options === 'string' ? undefined : options.externalLayoutName); @@ -176,6 +179,7 @@ namespace gdjs { externalLayoutName, getExcludedObjectNames, skipStoppingSoundsOnStartup, + skipCreatingInstances, }); } @@ -186,6 +190,7 @@ namespace gdjs { externalLayoutName, getExcludedObjectNames, skipStoppingSoundsOnStartup, + skipCreatingInstances, }); this._isNextLayoutLoading = false; }); @@ -205,6 +210,7 @@ namespace gdjs { ? options.getExcludedObjectNames(newScene) : undefined, skipStoppingSoundsOnStartup: options.skipStoppingSoundsOnStartup, + skipCreatingInstances: options.skipCreatingInstances, } ); this._wasFirstSceneLoaded = true; diff --git a/GDJS/Runtime/spriteruntimeobject.ts b/GDJS/Runtime/spriteruntimeobject.ts index aa798f43e0..fe11fb317f 100644 --- a/GDJS/Runtime/spriteruntimeobject.ts +++ b/GDJS/Runtime/spriteruntimeobject.ts @@ -190,15 +190,13 @@ namespace gdjs { this.setWidth(initialInstanceData.width); this.setHeight(initialInstanceData.height); } - if (initialInstanceData.opacity !== undefined) { - this.setOpacity(initialInstanceData.opacity); - } - if (initialInstanceData.flippedX) { - this.flipX(initialInstanceData.flippedX); - } - if (initialInstanceData.flippedY) { - this.flipY(initialInstanceData.flippedY); - } + this.setOpacity( + initialInstanceData.opacity === undefined + ? 255 + : initialInstanceData.opacity + ); + this.flipX(!!initialInstanceData.flippedX); + this.flipY(!!initialInstanceData.flippedY); } /** @@ -628,17 +626,15 @@ namespace gdjs { */ getCenterX(): float { const animationFrame = this._animator.getCurrentFrame(); - if (animationFrame === null) { - return 0; - } + const centerX = animationFrame + ? animationFrame.center.x + : this._renderer.getUnscaledWidth() / 2; if (!this._flippedX) { //Just need to multiply by the scale as it is the center. - return ( - animationFrame.center.x * Math.abs(this._scaleX * this._preScale) - ); + return centerX * Math.abs(this._scaleX * this._preScale); } else { return ( - (this._renderer.getUnscaledWidth() - animationFrame.center.x) * + (this._renderer.getUnscaledWidth() - centerX) * Math.abs(this._scaleX * this._preScale) ); } @@ -650,17 +646,15 @@ namespace gdjs { */ getCenterY(): float { const animationFrame = this._animator.getCurrentFrame(); - if (animationFrame === null) { - return 0; - } + const centerY = animationFrame + ? animationFrame.center.y + : this._renderer.getUnscaledHeight() / 2; if (!this._flippedY) { //Just need to multiply by the scale as it is the center. - return ( - animationFrame.center.y * Math.abs(this._scaleY * this._preScale) - ); + return centerY * Math.abs(this._scaleY * this._preScale); } else { return ( - (this._renderer.getUnscaledHeight() - animationFrame.center.y) * + (this._renderer.getUnscaledHeight() - centerY) * Math.abs(this._scaleY * this._preScale) ); } @@ -675,11 +669,8 @@ namespace gdjs { return; } this.x = x; - const animationFrame = this._animator.getCurrentFrame(); - if (animationFrame !== null) { - this.invalidateHitboxes(); - this._renderer.updateX(); - } + this.invalidateHitboxes(); + this._renderer.updateX(); } /** @@ -691,11 +682,8 @@ namespace gdjs { return; } this.y = y; - const animationFrame = this._animator.getCurrentFrame(); - if (animationFrame !== null) { - this.invalidateHitboxes(); - this._renderer.updateY(); - } + this.invalidateHitboxes(); + this._renderer.updateY(); } /** @@ -853,6 +841,14 @@ namespace gdjs { this.setHeight(newHeight); } + override getOriginalWidth(): float { + return this._renderer.getUnscaledWidth() * this._preScale; + } + + override getOriginalHeight(): float { + return this._renderer.getUnscaledHeight() * this._preScale; + } + /** * Change the scale on X and Y axis of the object. * diff --git a/GDJS/Runtime/types/global-three-addons.d.ts b/GDJS/Runtime/types/global-three-addons.d.ts index 5de5e672cf..3543542f98 100644 --- a/GDJS/Runtime/types/global-three-addons.d.ts +++ b/GDJS/Runtime/types/global-three-addons.d.ts @@ -2,7 +2,10 @@ 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 { SelectionBox } from 'three/examples/jsm/interactive/SelectionBox'; 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 +24,10 @@ declare global { GLTF, DRACOLoader, SkeletonUtils, + TransformControls, + SelectionBox, EffectComposer, + OutlinePass, Pass, RenderPass, ShaderPass, diff --git a/GDJS/Runtime/types/project-data.d.ts b/GDJS/Runtime/types/project-data.d.ts index 79bf831ce4..753bbc4c2c 100644 --- a/GDJS/Runtime/types/project-data.d.ts +++ b/GDJS/Runtime/types/project-data.d.ts @@ -18,6 +18,7 @@ declare interface ProjectData { layouts: LayoutData[]; externalLayouts: ExternalLayoutData[]; eventsFunctionsExtensions: EventsFunctionsExtensionData[]; + activatedByDefaultInEditorBehaviors?: Array; } declare interface EventsFunctionsVariablesData { @@ -290,6 +291,21 @@ declare interface LayoutData extends InstanceContainerData { usedResources: ResourceReference[]; resourcesPreloading?: 'at-startup' | 'never' | 'inherit'; resourcesUnloading?: 'at-scene-exit' | 'never' | 'inherit'; + uiSettings: InstancesEditorSettings; +} + +declare interface InstancesEditorSettings { + grid: boolean; + gridType: 'rectangular' | 'isometric'; + gridWidth: float; + gridHeight: float; + gridDepth?: float; + gridOffsetX: float; + gridOffsetY: float; + gridOffsetZ?: float; + gridColor: int; + gridAlpha: float; + snap: boolean; } declare interface LayoutNetworkSyncData { @@ -385,6 +401,8 @@ declare interface EventsBasedObjectVariantData extends InstanceContainerData { instances: InstanceData[]; objects: ObjectData[]; layers: LayerData[]; + usedResources: ResourceReference[]; + editionSettings: InstancesEditorSettings; } declare interface BehaviorSharedData { @@ -396,13 +414,17 @@ declare interface ExternalLayoutData { name: string; associatedLayout: string; instances: InstanceData[]; + editionSettings: InstancesEditorSettings; } -declare interface InstanceData { +declare interface InstancePersistentUuidData { persistentUuid: string; +} +declare interface InstanceData extends InstancePersistentUuidData { layer: string; - locked: boolean; + locked?: boolean; + sealed?: boolean; name: string; x: number; @@ -447,14 +469,18 @@ declare interface LayerData { visibility: boolean; cameras: CameraData[]; effects: EffectData[]; + /** Used by `InGameEditor` to toggle effects */ + _hiddenEffects?: EffectData[]; ambientLightColorR: number; ambientLightColorG: number; ambientLightColorB: number; camera3DFieldOfView?: float; camera3DFarPlaneDistance?: float; camera3DNearPlaneDistance?: float; + camera2DPlaneMaxDrawingDistance?: float; isLightingLayer: boolean; followBaseLayerCamera: boolean; + isLocked?: boolean; } declare interface CameraData { @@ -499,7 +525,7 @@ declare interface ProjectPropertiesData { pixelsRounding: boolean; antialiasingMode: 'none' | 'MSAA'; antialisingEnabledOnMobile: boolean; - sizeOnStartupMode: string; + sizeOnStartupMode: '' | 'scaleOuter' | 'adaptWidth' | 'adaptHeight'; version: string; name: string; author: string; @@ -519,6 +545,7 @@ declare interface ProjectPropertiesData { projectUuid?: string; sceneResourcesPreloading?: 'at-startup' | 'never'; sceneResourcesUnloading?: 'at-scene-exit' | 'never'; + areEffectsHiddenInEditor?: boolean; } declare interface ExtensionProperty { @@ -587,4 +614,5 @@ declare type ResourceKind = | 'model3D' | 'atlas' | 'spine' + | 'internal-in-game-editor-only-svg' | 'fake-resource-kind-for-testing-only'; diff --git a/GDJS/scripts/lib/runtime-files-list.js b/GDJS/scripts/lib/runtime-files-list.js index dc73751bfe..bb2d7bad76 100644 --- a/GDJS/scripts/lib/runtime-files-list.js +++ b/GDJS/scripts/lib/runtime-files-list.js @@ -19,6 +19,8 @@ const allowedExtensions = [ '.map', '.wasm', '.txt', + '.png', + '.svg', ]; // These extensions will be built with esbuild (the other will be copied). @@ -26,7 +28,7 @@ const transformIncludedExtensions = ['.js', '.ts', '.tsx']; // Among the files matching the previous extensions, these extensions won't be built with esbuild // (they will be copied). -const transformExcludedExtensions = ['.min.js', '.d.ts']; +const transformExcludedExtensions = ['.min.js', '.d.ts', '.png', '.svg']; // Files under these paths (relative to the GDevelop root path) won't // be built with esbuild, but simply copied. diff --git a/GDJS/tests/benchmarks/runtimeobject.js b/GDJS/tests/benchmarks/runtimeobject.js index 32b20db99f..7cc42d2eb8 100644 --- a/GDJS/tests/benchmarks/runtimeobject.js +++ b/GDJS/tests/benchmarks/runtimeobject.js @@ -1,6 +1,7 @@ describe('gdjs.RuntimeObject', function() { - const runtimeScene = new gdjs.RuntimeScene(null); - + const runtimeGame = gdjs.getPixiRuntimeGame(); + const runtimeScene = new gdjs.RuntimeScene(runtimeGame); + it('benchmark getAABB of rotated vs non rotated objects', function(){ this.timeout(20000); var object = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); @@ -21,7 +22,7 @@ describe('gdjs.RuntimeObject', function() { object.setX(i); object.getAABB(); }); - + console.log(benchmarkSuite.run()); }); @@ -49,7 +50,7 @@ describe('gdjs.RuntimeObject', function() { object.setX(i); object.getAABB(); }); - + console.log(benchmarkSuite.run()); }); }); diff --git a/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js b/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js index 14b7421bfb..dfbb797731 100644 --- a/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js +++ b/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js @@ -77,6 +77,19 @@ gdjs.getPixiRuntimeGameWithAssets = () => { title: '', variables: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + } }, ], externalLayouts: [], @@ -210,6 +223,7 @@ gdjs.getPixiRuntimeGameWithAssets = () => { areaMaxY: 64, areaMaxZ: 0, _initialInnerArea: null, + variants: [], }, ], sceneVariables: [], diff --git a/GDJS/tests/tests/ResourceLoader.js b/GDJS/tests/tests/ResourceLoader.js index 3ce10c78f4..5dd6867461 100644 --- a/GDJS/tests/tests/ResourceLoader.js +++ b/GDJS/tests/tests/ResourceLoader.js @@ -22,6 +22,19 @@ describe('gdjs.ResourceLoader', () => { title: '', variables: [], usedResources, + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + } }; }; diff --git a/GDJS/tests/tests/common.js b/GDJS/tests/tests/common.js index e9bf055448..55b23964a7 100644 --- a/GDJS/tests/tests/common.js +++ b/GDJS/tests/tests/common.js @@ -29,7 +29,8 @@ describe('gdjs.evtTools.object.twoListsTest', function() { var map1 = new Hashtable(); var map2 = new Hashtable(); - var runtimeScene = new gdjs.RuntimeScene(null); + const runtimeGame = gdjs.getPixiRuntimeGame(); + var runtimeScene = new gdjs.RuntimeScene(runtimeGame); var obj1A = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1B = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1C = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); @@ -64,7 +65,8 @@ describe('gdjs.evtTools.object.pickObjectsIf', function() { it('should properly pick objects', function(){ var map1 = new Hashtable(); - var runtimeScene = new gdjs.RuntimeScene(null); + const runtimeGame = gdjs.getPixiRuntimeGame(); + var runtimeScene = new gdjs.RuntimeScene(runtimeGame); var obj1A = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1B = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1C = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); @@ -88,7 +90,8 @@ describe('gdjs.evtTools.object.pickObjectsIf', function() { describe('gdjs.evtTools.object.pickRandomObject', function() { it('should pick only one object', function(){ - var runtimeScene = new gdjs.RuntimeScene(null); + const runtimeGame = gdjs.getPixiRuntimeGame(); + var runtimeScene = new gdjs.RuntimeScene(runtimeGame); var obj1A = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1B = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1C = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); @@ -118,7 +121,8 @@ describe('gdjs.evtTools.object.pickRandomObject', function() { describe('gdjs.evtTools.object.pickOnly', function() { it('picks only the object passed as parameter', function(){ - var runtimeScene = new gdjs.RuntimeScene(null); + const runtimeGame = gdjs.getPixiRuntimeGame(); + var runtimeScene = new gdjs.RuntimeScene(runtimeGame); var obj1A = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1B = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1C = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); @@ -154,7 +158,8 @@ describe('gdjs.evtTools.object.pickOnly', function() { describe('gdjs.evtTools.object.pickNearestObject', function() { var map1 = new Hashtable(); - var runtimeScene = new gdjs.RuntimeScene(null); + const runtimeGame = gdjs.getPixiRuntimeGame(); + var runtimeScene = new gdjs.RuntimeScene(runtimeGame); var obj1A = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1B = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); var obj1C = new gdjs.RuntimeObject(runtimeScene, {name: "obj1", type: "", behaviors: [], effects: []}); diff --git a/GDJS/tests/tests/effects.js b/GDJS/tests/tests/effects.js index d2342c7e4f..69f7595360 100644 --- a/GDJS/tests/tests/effects.js +++ b/GDJS/tests/tests/effects.js @@ -107,6 +107,19 @@ describe('gdjs.EffectsManager', () => { objects: [], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + } }, usedExtensionsWithVariablesData: []}); const runtimeLayer = runtimeScene.getLayer(''); diff --git a/GDJS/tests/tests/hot-reloader.js b/GDJS/tests/tests/hot-reloader.js index 2249bfb43d..a0ea19bcc1 100644 --- a/GDJS/tests/tests/hot-reloader.js +++ b/GDJS/tests/tests/hot-reloader.js @@ -185,6 +185,19 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => { ? instances.map((instance) => ({ ...defaultInstance, ...instance })) : [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + } }; }; @@ -217,6 +230,20 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => { _initialInnerArea: null, isInnerAreaFollowingParentSize: false, variants: [], + usedResources: [], + editionSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + } }; }; diff --git a/GDJS/tests/tests/runtimescene.js b/GDJS/tests/tests/runtimescene.js index ae49bdf785..ac2896cbee 100644 --- a/GDJS/tests/tests/runtimescene.js +++ b/GDJS/tests/tests/runtimescene.js @@ -47,6 +47,19 @@ describe('gdjs.RuntimeScene integration tests', function () { ], instances: [], usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + } }, usedExtensionsWithVariablesData: []}); const object = runtimeScene.createObject('Object1'); diff --git a/GDJS/tests/tests/scenestack.js b/GDJS/tests/tests/scenestack.js index c374d3c62d..d31880fa9e 100644 --- a/GDJS/tests/tests/scenestack.js +++ b/GDJS/tests/tests/scenestack.js @@ -21,6 +21,20 @@ describe('gdjs.SceneStack', () => { title: '', variables: [], usedResources, + uiSettings: { + grid: false, + /** @type {"rectangular" | "isometric"} */ + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, }; }; diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index fe3b7deb06..e87b0d08b4 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -592,6 +592,8 @@ interface Project { boolean IsFolderProject(); void SetUseDeprecatedZeroAsDefaultZOrder(boolean enable); boolean GetUseDeprecatedZeroAsDefaultZOrder(); + boolean AreEffectsHiddenInEditor(); + void SetEffectsHiddenInEditor(boolean enable); void SetLastCompilationDirectory([Const] DOMString path); [Const, Ref] DOMString GetLastCompilationDirectory(); @@ -772,6 +774,12 @@ interface ExtensionProperties { void UnserializeFrom([Const, Ref] SerializerElement element); }; +interface BehaviorDefaultFlagClearer { + void STATIC_SerializeObjectWithCleanDefaultBehaviorFlags( + [Const, Ref] gdObject obj, + [Ref] SerializerElement serializerElement); +}; + interface Behavior { void Behavior(); @@ -1109,6 +1117,8 @@ interface Layer { void SetCamera3DFarPlaneDistance(double distance); double GetCamera3DFieldOfView(); void SetCamera3DFieldOfView(double angle); + double GetCamera2DPlaneMaxDrawingDistance(); + void SetCamera2DPlaneMaxDrawingDistance(double distance); void SetAmbientLightColor(unsigned long r, unsigned long g, unsigned long b); unsigned long GetAmbientLightColorRed(); @@ -1423,6 +1433,13 @@ interface InitialInstance { void SetCustomDepth(double depth); double GetCustomDepth(); + double GetDefaultWidth(); + double GetDefaultHeight(); + double GetDefaultDepth(); + void SetDefaultWidth(double width); + void SetDefaultHeight(double height); + void SetDefaultDepth(double depth); + [Ref] InitialInstance ResetPersistentUuid(); [Const, Ref] DOMString GetPersistentUuid(); @@ -1912,6 +1929,7 @@ interface ObjectMetadata { [Const, Ref] DOMString GetCategoryFullName(); [Ref] ObjectMetadata SetCategoryFullName([Const] DOMString categoryFullName); + [Ref] InGameEditorResourceMetadata AddInGameEditorResource(); [Const, Ref] InstructionMetadata AddScopedCondition([Const] DOMString name, [Const] DOMString fullname, @@ -2002,6 +2020,12 @@ interface ObjectMetadata { [Const, Ref] DOMString GetOpenFullEditorLabel(); }; +interface InGameEditorResourceMetadata { + [Ref] InGameEditorResourceMetadata SetResourceName([Const] DOMString resourceName); + [Ref] InGameEditorResourceMetadata SetFilePath([Const] DOMString relativeFilePath); + [Ref] InGameEditorResourceMetadata SetKind([Const] DOMString kind); +}; + enum QuickCustomization_Visibility { "QuickCustomization::Default", "QuickCustomization::Visible", @@ -2140,6 +2164,9 @@ interface BehaviorMetadata { boolean IsRelevantForChildObjects(); [Ref] BehaviorMetadata MarkAsIrrelevantForChildObjects(); + boolean IsActivatedByDefaultInEditor(); + [Ref] BehaviorMetadata MarkAsActivatedByDefaultInEditor(); + QuickCustomization_Visibility GetQuickCustomizationVisibility(); [Ref] BehaviorMetadata SetQuickCustomizationVisibility(QuickCustomization_Visibility visibility); @@ -2866,6 +2893,10 @@ interface UsedExtensionsFinder { [Value] UsedExtensionsResult STATIC_ScanProject([Ref] Project project); }; +interface UsedObjectTypeFinder { + boolean STATIC_ScanProject([Ref] Project project, [Const] DOMString objectType); +}; + interface ExampleExtensionUsagesFinder { [Value] SetString STATIC_GetUsedExtensions([Ref] Project project); }; @@ -3995,11 +4026,27 @@ interface PreviewExportOptions { [Ref] PreviewExportOptions SetFallbackAuthor([Const] DOMString id, [Const] DOMString username); [Ref] PreviewExportOptions SetAuthenticatedPlayer([Const] DOMString playerId, [Const] DOMString playerUsername, [Const] DOMString playerToken); [Ref] PreviewExportOptions SetExternalLayoutName([Const] DOMString externalLayoutName); + [Ref] PreviewExportOptions SetEventsBasedObjectType([Const] DOMString eventsBasedObjectType); + [Ref] PreviewExportOptions SetEventsBasedObjectVariantName([Const] DOMString eventsBasedObjectVariantName); [Ref] PreviewExportOptions SetIncludeFileHash([Const] DOMString includeFile, long hash); - [Ref] PreviewExportOptions SetProjectDataOnlyExport(boolean enable); + [Ref] PreviewExportOptions SetShouldClearExportFolder(boolean enable); + [Ref] PreviewExportOptions SetShouldReloadProjectData(boolean enable); + [Ref] PreviewExportOptions SetShouldReloadLibraries(boolean enable); + [Ref] PreviewExportOptions SetShouldGenerateScenesEventsCode(boolean enable); [Ref] PreviewExportOptions SetNativeMobileApp(boolean enable); [Ref] PreviewExportOptions SetFullLoadingScreen(boolean enable); [Ref] PreviewExportOptions SetIsDevelopmentEnvironment(boolean enable); + [Ref] PreviewExportOptions SetIsInGameEdition(boolean enable); + [Ref] PreviewExportOptions SetInGameEditorSettingsJson([Const] DOMString inGameEditorSettingsJson); + [Ref] PreviewExportOptions SetEditorId([Const] DOMString editorId); + [Ref] PreviewExportOptions SetEditorCameraState3D( + [Const] DOMString cameraMode, + double positionX, + double positionY, + double positionZ, + double rotationAngle, + double elevationAngle, + double distance); [Ref] PreviewExportOptions SetNonRuntimeScriptsCacheBurst(unsigned long value); [Ref] PreviewExportOptions SetElectronRemoteRequirePath([Const] DOMString electronRemoteRequirePath); [Ref] PreviewExportOptions SetGDevelopResourceToken([Const] DOMString gdevelopResourceToken); @@ -4026,6 +4073,13 @@ interface Exporter { boolean ExportProjectForPixiPreview([Const, Ref] PreviewExportOptions options); boolean ExportWholePixiProject([Const, Ref] ExportOptions options); + void SerializeProjectData( + [Const, Ref] Project project, + [Const, Ref] PreviewExportOptions options, + [Ref] SerializerElement projectDataElement); + void SerializeRuntimeGameOptions( + [Const, Ref] PreviewExportOptions options, + [Ref] SerializerElement runtimeGameOptionsElement); [Const, Ref] DOMString GetLastError(); }; diff --git a/GDevelop.js/Bindings/Wrapper.cpp b/GDevelop.js/Bindings/Wrapper.cpp index 1e075e2a4d..4439af5565 100644 --- a/GDevelop.js/Bindings/Wrapper.cpp +++ b/GDevelop.js/Bindings/Wrapper.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -826,6 +828,8 @@ typedef std::vector VectorPropertyDescriptorChoice #define STATIC_ScanProject ScanProject #define STATIC_GetUsedExtensions GetUsedExtensions +#define STATIC_SerializeProjectData SerializeProjectData +#define STATIC_SerializeObjectWithCleanDefaultBehaviorFlags SerializeObjectWithCleanDefaultBehaviorFlags #define STATIC_ApplyTranslation ApplyTranslation #define STATIC_GetUndefined GetUndefined diff --git a/GDevelop.js/__tests__/Core.js b/GDevelop.js/__tests__/Core.js index 9d44f033c7..6da171ccfe 100644 --- a/GDevelop.js/__tests__/Core.js +++ b/GDevelop.js/__tests__/Core.js @@ -151,7 +151,11 @@ describe('libGD.js', function () { .getUsedExtensions() .toNewVectorString() .toJSArray() - ).toEqual([]); + ).toEqual([ + // This extension is always considerde as used, as it + // includes files necessary for the in-game editor. + 'BuiltinObject', + ]); project.getObjects().insertNewObject(project, 'Sprite', 'MyObject', 0); @@ -162,6 +166,7 @@ describe('libGD.js', function () { .toJSArray() ).toEqual([ 'AnimatableCapability', + 'BuiltinObject', 'EffectCapability', 'FlippableCapability', 'OpacityCapability', diff --git a/GDevelop.js/scripts/generate-types.js b/GDevelop.js/scripts/generate-types.js index b2265c2efd..6c8d798336 100644 --- a/GDevelop.js/scripts/generate-types.js +++ b/GDevelop.js/scripts/generate-types.js @@ -423,6 +423,14 @@ type CustomObjectConfiguration_EdgeAnchor = 0 | 1 | 2 | 3 | 4` 'types/gdexporter.js' ); + // Rename classes from GDJS: + shell.sed( + '-i', + 'declare class gdExporterHelper {', + 'declare class gdjsExporterHelper {', + 'types/gdexporter.js' + ); + // Improve typing of resources kind. shell.sed( '-i', diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 643175e579..6dc10aeaf9 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -557,6 +557,8 @@ export class Project extends EmscriptenObject { isFolderProject(): boolean; setUseDeprecatedZeroAsDefaultZOrder(enable: boolean): void; getUseDeprecatedZeroAsDefaultZOrder(): boolean; + areEffectsHiddenInEditor(): boolean; + setEffectsHiddenInEditor(enable: boolean): void; setLastCompilationDirectory(path: string): void; getLastCompilationDirectory(): string; getExtensionProperties(): ExtensionProperties; @@ -664,6 +666,10 @@ export class ExtensionProperties extends EmscriptenObject { unserializeFrom(element: SerializerElement): void; } +export class BehaviorDefaultFlagClearer extends EmscriptenObject { + static serializeObjectWithCleanDefaultBehaviorFlags(obj: gdObject, serializerElement: SerializerElement): void; +} + export class Behavior extends EmscriptenObject { constructor(); clone(): Behavior; @@ -915,6 +921,8 @@ export class Layer extends EmscriptenObject { setCamera3DFarPlaneDistance(distance: number): void; getCamera3DFieldOfView(): number; setCamera3DFieldOfView(angle: number): void; + getCamera2DPlaneMaxDrawingDistance(): number; + setCamera2DPlaneMaxDrawingDistance(distance: number): void; setAmbientLightColor(r: number, g: number, b: number): void; getAmbientLightColorRed(): number; getAmbientLightColorGreen(): number; @@ -1185,6 +1193,12 @@ export class InitialInstance extends EmscriptenObject { getCustomHeight(): number; setCustomDepth(depth: number): void; getCustomDepth(): number; + getDefaultWidth(): number; + getDefaultHeight(): number; + getDefaultDepth(): number; + setDefaultWidth(width: number): void; + setDefaultHeight(height: number): void; + setDefaultDepth(depth: number): void; resetPersistentUuid(): InitialInstance; getPersistentUuid(): string; updateCustomProperty(name: string, value: string, globalObjectsContainer: ObjectsContainer, objectsContainer: ObjectsContainer): void; @@ -1578,6 +1592,7 @@ export class ObjectMetadata extends EmscriptenObject { getHelpPath(): string; getCategoryFullName(): string; setCategoryFullName(categoryFullName: string): ObjectMetadata; + addInGameEditorResource(): InGameEditorResourceMetadata; addScopedCondition(name: string, fullname: string, description: string, sentence: string, group: string, icon: string, smallicon: string): InstructionMetadata; addScopedAction(name: string, fullname: string, description: string, sentence: string, group: string, icon: string, smallicon: string): InstructionMetadata; addCondition(name: string, fullname: string, description: string, sentence: string, group: string, icon: string, smallicon: string): InstructionMetadata; @@ -1605,6 +1620,12 @@ export class ObjectMetadata extends EmscriptenObject { getOpenFullEditorLabel(): string; } +export class InGameEditorResourceMetadata extends EmscriptenObject { + setResourceName(resourceName: string): InGameEditorResourceMetadata; + setFilePath(relativeFilePath: string): InGameEditorResourceMetadata; + setKind(kind: string): InGameEditorResourceMetadata; +} + export class QuickCustomization extends EmscriptenObject { static Default = 0; static Visible = 1; @@ -1667,6 +1688,8 @@ export class BehaviorMetadata extends EmscriptenObject { setHidden(): BehaviorMetadata; isRelevantForChildObjects(): boolean; markAsIrrelevantForChildObjects(): BehaviorMetadata; + isActivatedByDefaultInEditor(): boolean; + markAsActivatedByDefaultInEditor(): BehaviorMetadata; getQuickCustomizationVisibility(): QuickCustomization_Visibility; setQuickCustomizationVisibility(visibility: QuickCustomization_Visibility): BehaviorMetadata; setOpenFullEditorLabel(label: string): BehaviorMetadata; @@ -2046,6 +2069,10 @@ export class UsedExtensionsFinder extends EmscriptenObject { static scanProject(project: Project): UsedExtensionsResult; } +export class UsedObjectTypeFinder extends EmscriptenObject { + static scanProject(project: Project, objectType: string): boolean; +} + export class ExampleExtensionUsagesFinder extends EmscriptenObject { static getUsedExtensions(project: Project): SetString; } @@ -2960,11 +2987,20 @@ export class PreviewExportOptions extends EmscriptenObject { setFallbackAuthor(id: string, username: string): PreviewExportOptions; setAuthenticatedPlayer(playerId: string, playerUsername: string, playerToken: string): PreviewExportOptions; setExternalLayoutName(externalLayoutName: string): PreviewExportOptions; + setEventsBasedObjectType(eventsBasedObjectType: string): PreviewExportOptions; + setEventsBasedObjectVariantName(eventsBasedObjectVariantName: string): PreviewExportOptions; setIncludeFileHash(includeFile: string, hash: number): PreviewExportOptions; - setProjectDataOnlyExport(enable: boolean): PreviewExportOptions; + setShouldClearExportFolder(enable: boolean): PreviewExportOptions; + setShouldReloadProjectData(enable: boolean): PreviewExportOptions; + setShouldReloadLibraries(enable: boolean): PreviewExportOptions; + setShouldGenerateScenesEventsCode(enable: boolean): PreviewExportOptions; setNativeMobileApp(enable: boolean): PreviewExportOptions; setFullLoadingScreen(enable: boolean): PreviewExportOptions; setIsDevelopmentEnvironment(enable: boolean): PreviewExportOptions; + setIsInGameEdition(enable: boolean): PreviewExportOptions; + setInGameEditorSettingsJson(inGameEditorSettingsJson: string): PreviewExportOptions; + setEditorId(editorId: string): PreviewExportOptions; + setEditorCameraState3D(cameraMode: string, positionX: number, positionY: number, positionZ: number, rotationAngle: number, elevationAngle: number, distance: number): PreviewExportOptions; setNonRuntimeScriptsCacheBurst(value: number): PreviewExportOptions; setElectronRemoteRequirePath(electronRemoteRequirePath: string): PreviewExportOptions; setGDevelopResourceToken(gdevelopResourceToken: string): PreviewExportOptions; @@ -2988,6 +3024,8 @@ export class Exporter extends EmscriptenObject { setCodeOutputDirectory(path: string): void; exportProjectForPixiPreview(options: PreviewExportOptions): boolean; exportWholePixiProject(options: ExportOptions): boolean; + serializeProjectData(project: Project, options: PreviewExportOptions, projectDataElement: SerializerElement): void; + serializeRuntimeGameOptions(options: PreviewExportOptions, runtimeGameOptionsElement: SerializerElement): void; getLastError(): string; } diff --git a/GDevelop.js/types/gdbehaviordefaultflagclearer.js b/GDevelop.js/types/gdbehaviordefaultflagclearer.js new file mode 100644 index 0000000000..cc88fbdff5 --- /dev/null +++ b/GDevelop.js/types/gdbehaviordefaultflagclearer.js @@ -0,0 +1,6 @@ +// Automatically generated by GDevelop.js/scripts/generate-types.js +declare class gdBehaviorDefaultFlagClearer { + static serializeObjectWithCleanDefaultBehaviorFlags(obj: gdObject, serializerElement: gdSerializerElement): void; + delete(): void; + ptr: number; +}; \ No newline at end of file diff --git a/GDevelop.js/types/gdbehaviormetadata.js b/GDevelop.js/types/gdbehaviormetadata.js index ec37951bcd..bd658c0fab 100644 --- a/GDevelop.js/types/gdbehaviormetadata.js +++ b/GDevelop.js/types/gdbehaviormetadata.js @@ -35,6 +35,8 @@ declare class gdBehaviorMetadata { setHidden(): gdBehaviorMetadata; isRelevantForChildObjects(): boolean; markAsIrrelevantForChildObjects(): gdBehaviorMetadata; + isActivatedByDefaultInEditor(): boolean; + markAsActivatedByDefaultInEditor(): gdBehaviorMetadata; getQuickCustomizationVisibility(): QuickCustomization_Visibility; setQuickCustomizationVisibility(visibility: QuickCustomization_Visibility): gdBehaviorMetadata; setOpenFullEditorLabel(label: string): gdBehaviorMetadata; diff --git a/GDevelop.js/types/gdexporter.js b/GDevelop.js/types/gdexporter.js index 9cd010289b..1f8cfa0d9c 100644 --- a/GDevelop.js/types/gdexporter.js +++ b/GDevelop.js/types/gdexporter.js @@ -4,6 +4,8 @@ declare class gdjsExporter { setCodeOutputDirectory(path: string): void; exportProjectForPixiPreview(options: gdPreviewExportOptions): boolean; exportWholePixiProject(options: gdExportOptions): boolean; + serializeProjectData(project: gdProject, options: gdPreviewExportOptions, projectDataElement: gdSerializerElement): void; + serializeRuntimeGameOptions(options: gdPreviewExportOptions, runtimeGameOptionsElement: gdSerializerElement): void; getLastError(): string; delete(): void; ptr: number; diff --git a/GDevelop.js/types/gdingameeditorresourcemetadata.js b/GDevelop.js/types/gdingameeditorresourcemetadata.js new file mode 100644 index 0000000000..ab7fe1f8d3 --- /dev/null +++ b/GDevelop.js/types/gdingameeditorresourcemetadata.js @@ -0,0 +1,8 @@ +// Automatically generated by GDevelop.js/scripts/generate-types.js +declare class gdInGameEditorResourceMetadata { + setResourceName(resourceName: string): gdInGameEditorResourceMetadata; + setFilePath(relativeFilePath: string): gdInGameEditorResourceMetadata; + setKind(kind: string): gdInGameEditorResourceMetadata; + delete(): void; + ptr: number; +}; \ No newline at end of file diff --git a/GDevelop.js/types/gdinitialinstance.js b/GDevelop.js/types/gdinitialinstance.js index 57e0a5eb62..bfd726b91b 100644 --- a/GDevelop.js/types/gdinitialinstance.js +++ b/GDevelop.js/types/gdinitialinstance.js @@ -43,6 +43,12 @@ declare class gdInitialInstance { getCustomHeight(): number; setCustomDepth(depth: number): void; getCustomDepth(): number; + getDefaultWidth(): number; + getDefaultHeight(): number; + getDefaultDepth(): number; + setDefaultWidth(width: number): void; + setDefaultHeight(height: number): void; + setDefaultDepth(depth: number): void; resetPersistentUuid(): gdInitialInstance; getPersistentUuid(): string; updateCustomProperty(name: string, value: string, globalObjectsContainer: gdObjectsContainer, objectsContainer: gdObjectsContainer): void; diff --git a/GDevelop.js/types/gdlayer.js b/GDevelop.js/types/gdlayer.js index 72addae4af..b26cb44e49 100644 --- a/GDevelop.js/types/gdlayer.js +++ b/GDevelop.js/types/gdlayer.js @@ -23,6 +23,8 @@ declare class gdLayer { setCamera3DFarPlaneDistance(distance: number): void; getCamera3DFieldOfView(): number; setCamera3DFieldOfView(angle: number): void; + getCamera2DPlaneMaxDrawingDistance(): number; + setCamera2DPlaneMaxDrawingDistance(distance: number): void; setAmbientLightColor(r: number, g: number, b: number): void; getAmbientLightColorRed(): number; getAmbientLightColorGreen(): number; diff --git a/GDevelop.js/types/gdobjectmetadata.js b/GDevelop.js/types/gdobjectmetadata.js index 71baff2af1..54f2ac0c4b 100644 --- a/GDevelop.js/types/gdobjectmetadata.js +++ b/GDevelop.js/types/gdobjectmetadata.js @@ -7,6 +7,7 @@ declare class gdObjectMetadata { getHelpPath(): string; getCategoryFullName(): string; setCategoryFullName(categoryFullName: string): gdObjectMetadata; + addInGameEditorResource(): gdInGameEditorResourceMetadata; addScopedCondition(name: string, fullname: string, description: string, sentence: string, group: string, icon: string, smallicon: string): gdInstructionMetadata; addScopedAction(name: string, fullname: string, description: string, sentence: string, group: string, icon: string, smallicon: string): gdInstructionMetadata; addCondition(name: string, fullname: string, description: string, sentence: string, group: string, icon: string, smallicon: string): gdInstructionMetadata; diff --git a/GDevelop.js/types/gdpreviewexportoptions.js b/GDevelop.js/types/gdpreviewexportoptions.js index 24a4152dd3..5cd6d5a86e 100644 --- a/GDevelop.js/types/gdpreviewexportoptions.js +++ b/GDevelop.js/types/gdpreviewexportoptions.js @@ -9,11 +9,20 @@ declare class gdPreviewExportOptions { setFallbackAuthor(id: string, username: string): gdPreviewExportOptions; setAuthenticatedPlayer(playerId: string, playerUsername: string, playerToken: string): gdPreviewExportOptions; setExternalLayoutName(externalLayoutName: string): gdPreviewExportOptions; + setEventsBasedObjectType(eventsBasedObjectType: string): gdPreviewExportOptions; + setEventsBasedObjectVariantName(eventsBasedObjectVariantName: string): gdPreviewExportOptions; setIncludeFileHash(includeFile: string, hash: number): gdPreviewExportOptions; - setProjectDataOnlyExport(enable: boolean): gdPreviewExportOptions; + setShouldClearExportFolder(enable: boolean): gdPreviewExportOptions; + setShouldReloadProjectData(enable: boolean): gdPreviewExportOptions; + setShouldReloadLibraries(enable: boolean): gdPreviewExportOptions; + setShouldGenerateScenesEventsCode(enable: boolean): gdPreviewExportOptions; setNativeMobileApp(enable: boolean): gdPreviewExportOptions; setFullLoadingScreen(enable: boolean): gdPreviewExportOptions; setIsDevelopmentEnvironment(enable: boolean): gdPreviewExportOptions; + setIsInGameEdition(enable: boolean): gdPreviewExportOptions; + setInGameEditorSettingsJson(inGameEditorSettingsJson: string): gdPreviewExportOptions; + setEditorId(editorId: string): gdPreviewExportOptions; + setEditorCameraState3D(cameraMode: string, positionX: number, positionY: number, positionZ: number, rotationAngle: number, elevationAngle: number, distance: number): gdPreviewExportOptions; setNonRuntimeScriptsCacheBurst(value: number): gdPreviewExportOptions; setElectronRemoteRequirePath(electronRemoteRequirePath: string): gdPreviewExportOptions; setGDevelopResourceToken(gdevelopResourceToken: string): gdPreviewExportOptions; diff --git a/GDevelop.js/types/gdproject.js b/GDevelop.js/types/gdproject.js index b0c6b27729..e0437a6be1 100644 --- a/GDevelop.js/types/gdproject.js +++ b/GDevelop.js/types/gdproject.js @@ -52,6 +52,8 @@ declare class gdProject { isFolderProject(): boolean; setUseDeprecatedZeroAsDefaultZOrder(enable: boolean): void; getUseDeprecatedZeroAsDefaultZOrder(): boolean; + areEffectsHiddenInEditor(): boolean; + setEffectsHiddenInEditor(enable: boolean): void; setLastCompilationDirectory(path: string): void; getLastCompilationDirectory(): string; getExtensionProperties(): gdExtensionProperties; diff --git a/GDevelop.js/types/gdusedobjecttypefinder.js b/GDevelop.js/types/gdusedobjecttypefinder.js new file mode 100644 index 0000000000..191d9f5690 --- /dev/null +++ b/GDevelop.js/types/gdusedobjecttypefinder.js @@ -0,0 +1,6 @@ +// Automatically generated by GDevelop.js/scripts/generate-types.js +declare class gdUsedObjectTypeFinder { + static scanProject(project: gdProject, objectType: string): boolean; + delete(): void; + ptr: number; +}; \ No newline at end of file diff --git a/GDevelop.js/types/libgdevelop.js b/GDevelop.js/types/libgdevelop.js index b7b3eaf898..b54f75d41d 100644 --- a/GDevelop.js/types/libgdevelop.js +++ b/GDevelop.js/types/libgdevelop.js @@ -87,6 +87,7 @@ declare class libGDevelop { ObjectsContainersList: Class; ProjectScopedContainers: Class; ExtensionProperties: Class; + BehaviorDefaultFlagClearer: Class; Behavior: Class; BehaviorJsImplementation: Class; BehaviorsSharedData: Class; @@ -157,6 +158,7 @@ declare class libGDevelop { ParameterMetadataContainer: Class; ParameterMetadataTools: Class; ObjectMetadata: Class; + InGameEditorResourceMetadata: Class; QuickCustomization_Visibility: Class; QuickCustomization: Class; QuickCustomizationVisibilitiesContainer: Class; @@ -192,6 +194,7 @@ declare class libGDevelop { PropertyFunctionGenerator: Class; UsedExtensionsResult: Class; UsedExtensionsFinder: Class; + UsedObjectTypeFinder: Class; ExampleExtensionUsagesFinder: Class; InstructionsCountEvaluator: Class; ExtensionAndBehaviorMetadata: Class; diff --git a/SharedLibs/ThreeAddons/src/examples/jsm/controls/TransformControls.js b/SharedLibs/ThreeAddons/src/examples/jsm/controls/TransformControls.js new file mode 100644 index 0000000000..f78ba2325a --- /dev/null +++ b/SharedLibs/ThreeAddons/src/examples/jsm/controls/TransformControls.js @@ -0,0 +1,1573 @@ +import { + BoxGeometry, + BufferGeometry, + CylinderGeometry, + DoubleSide, + Euler, + Float32BufferAttribute, + Line, + LineBasicMaterial, + Matrix4, + Mesh, + MeshBasicMaterial, + Object3D, + OctahedronGeometry, + PlaneGeometry, + Quaternion, + Raycaster, + SphereGeometry, + TorusGeometry, + Vector3 +} from 'three'; + +const _raycaster = new Raycaster(); + +const _tempVector = new Vector3(); +const _tempVector2 = new Vector3(); +const _tempQuaternion = new Quaternion(); +const _unit = { + X: new Vector3( 1, 0, 0 ), + Y: new Vector3( 0, 1, 0 ), + Z: new Vector3( 0, 0, 1 ) +}; + +const _changeEvent = { type: 'change' }; +const _mouseDownEvent = { type: 'mouseDown' }; +const _mouseUpEvent = { type: 'mouseUp', mode: null }; +const _objectChangeEvent = { type: 'objectChange' }; + +class TransformControls extends Object3D { + + constructor( camera, domElement ) { + + super(); + + if ( domElement === undefined ) { + + console.warn( 'THREE.TransformControls: The second parameter "domElement" is now mandatory.' ); + domElement = document; + + } + + this.isTransformControls = true; + + this.visible = false; + this.domElement = domElement; + this.domElement.style.touchAction = 'none'; // disable touch scroll + + const _gizmo = new TransformControlsGizmo(); + this._gizmo = _gizmo; + this.add( _gizmo ); + + const _plane = new TransformControlsPlane(); + this._plane = _plane; + this.add( _plane ); + + const scope = this; + + // Defined getter, setter and store for a property + function defineProperty( propName, defaultValue ) { + + let propValue = defaultValue; + + Object.defineProperty( scope, propName, { + + get: function () { + + return propValue !== undefined ? propValue : defaultValue; + + }, + + set: function ( value ) { + + if ( propValue !== value ) { + + propValue = value; + _plane[ propName ] = value; + _gizmo[ propName ] = value; + + scope.dispatchEvent( { type: propName + '-changed', value: value } ); + scope.dispatchEvent( _changeEvent ); + + } + + } + + } ); + + scope[ propName ] = defaultValue; + _plane[ propName ] = defaultValue; + _gizmo[ propName ] = defaultValue; + + } + + // Define properties with getters/setter + // Setting the defined property will automatically trigger change event + // Defined properties are passed down to gizmo and plane + + defineProperty( 'camera', camera ); + defineProperty( 'object', undefined ); + defineProperty( 'enabled', true ); + defineProperty( 'axis', null ); + defineProperty( 'mode', 'translate' ); + defineProperty( 'translationSnap', null ); + defineProperty( 'rotationSnap', null ); + defineProperty( 'scaleSnap', null ); + defineProperty( 'space', 'world' ); + defineProperty( 'size', 1 ); + defineProperty( 'dragging', false ); + defineProperty( 'showX', true ); + defineProperty( 'showY', true ); + defineProperty( 'showZ', true ); + + // Reusable utility variables + + const worldPosition = new Vector3(); + const worldPositionStart = new Vector3(); + const worldQuaternion = new Quaternion(); + const worldQuaternionStart = new Quaternion(); + const cameraPosition = new Vector3(); + const cameraQuaternion = new Quaternion(); + const pointStart = new Vector3(); + const pointEnd = new Vector3(); + const rotationAxis = new Vector3(); + const rotationAngle = 0; + const eye = new Vector3(); + + // TODO: remove properties unused in plane and gizmo + + defineProperty( 'worldPosition', worldPosition ); + defineProperty( 'worldPositionStart', worldPositionStart ); + defineProperty( 'worldQuaternion', worldQuaternion ); + defineProperty( 'worldQuaternionStart', worldQuaternionStart ); + defineProperty( 'cameraPosition', cameraPosition ); + defineProperty( 'cameraQuaternion', cameraQuaternion ); + defineProperty( 'pointStart', pointStart ); + defineProperty( 'pointEnd', pointEnd ); + defineProperty( 'rotationAxis', rotationAxis ); + defineProperty( 'rotationAngle', rotationAngle ); + defineProperty( 'eye', eye ); + + this._offset = new Vector3(); + this._startNorm = new Vector3(); + this._endNorm = new Vector3(); + this._cameraScale = new Vector3(); + + this._parentPosition = new Vector3(); + this._parentQuaternion = new Quaternion(); + this._parentQuaternionInv = new Quaternion(); + this._parentScale = new Vector3(); + + this._worldScaleStart = new Vector3(); + this._worldQuaternionInv = new Quaternion(); + this._worldScale = new Vector3(); + + this._positionStart = new Vector3(); + this._quaternionStart = new Quaternion(); + this._scaleStart = new Vector3(); + + this._getPointer = getPointer.bind( this ); + this._onPointerDown = onPointerDown.bind( this ); + this._onPointerHover = onPointerHover.bind( this ); + this._onPointerMove = onPointerMove.bind( this ); + this._onPointerUp = onPointerUp.bind( this ); + + this.domElement.addEventListener( 'pointerdown', this._onPointerDown ); + this.domElement.addEventListener( 'pointermove', this._onPointerHover ); + this.domElement.addEventListener( 'pointerup', this._onPointerUp ); + + } + + // updateMatrixWorld updates key transformation variables + updateMatrixWorld() { + + if ( this.object !== undefined ) { + + this.object.updateMatrixWorld(); + + if ( this.object.parent === null ) { + + console.error( 'TransformControls: The attached 3D object must be a part of the scene graph.' ); + + } else { + + this.object.parent.matrixWorld.decompose( this._parentPosition, this._parentQuaternion, this._parentScale ); + + } + + this.object.matrixWorld.decompose( this.worldPosition, this.worldQuaternion, this._worldScale ); + + this._parentQuaternionInv.copy( this._parentQuaternion ).invert(); + this._worldQuaternionInv.copy( this.worldQuaternion ).invert(); + + } + + this.camera.updateMatrixWorld(); + this.camera.matrixWorld.decompose( this.cameraPosition, this.cameraQuaternion, this._cameraScale ); + + if ( this.camera.isOrthographicCamera ) { + + this.camera.getWorldDirection( this.eye ).negate(); + + } else { + + this.eye.copy( this.cameraPosition ).sub( this.worldPosition ).normalize(); + + } + + super.updateMatrixWorld( this ); + + } + + pointerHover( pointer ) { + + if ( this.object === undefined || this.dragging === true ) return; + + _raycaster.setFromCamera( pointer, this.camera ); + + const intersect = intersectObjectWithRay( this._gizmo.picker[ this.mode ], _raycaster ); + + if ( intersect ) { + + this.axis = intersect.object.name; + + } else { + + this.axis = null; + + } + + } + + pointerDown( pointer ) { + + if ( this.object === undefined || this.dragging === true || pointer.button !== 0 ) return; + + if ( this.axis !== null ) { + + _raycaster.setFromCamera( pointer, this.camera ); + + const planeIntersect = intersectObjectWithRay( this._plane, _raycaster, true ); + + if ( planeIntersect ) { + + this.object.updateMatrixWorld(); + this.object.parent.updateMatrixWorld(); + + this._positionStart.copy( this.object.position ); + this._quaternionStart.copy( this.object.quaternion ); + this._scaleStart.copy( this.object.scale ); + + this.object.matrixWorld.decompose( this.worldPositionStart, this.worldQuaternionStart, this._worldScaleStart ); + + this.pointStart.copy( planeIntersect.point ).sub( this.worldPositionStart ); + + } + + this.dragging = true; + _mouseDownEvent.mode = this.mode; + this.dispatchEvent( _mouseDownEvent ); + + } + + } + + pointerMove( pointer ) { + + const axis = this.axis; + const mode = this.mode; + const object = this.object; + let space = this.space; + + if ( mode === 'scale' ) { + + space = 'local'; + + } else if ( axis === 'E' || axis === 'XYZE' || axis === 'XYZ' ) { + + space = 'world'; + + } + + if ( object === undefined || axis === null || this.dragging === false || pointer.button !== - 1 ) return; + + _raycaster.setFromCamera( pointer, this.camera ); + + const planeIntersect = intersectObjectWithRay( this._plane, _raycaster, true ); + + if ( ! planeIntersect ) return; + + this.pointEnd.copy( planeIntersect.point ).sub( this.worldPositionStart ); + + if ( mode === 'translate' ) { + + // Apply translate + + this._offset.copy( this.pointEnd ).sub( this.pointStart ); + + if ( space === 'local' && axis !== 'XYZ' ) { + + this._offset.applyQuaternion( this._worldQuaternionInv ); + + } + + if ( axis.indexOf( 'X' ) === - 1 ) this._offset.x = 0; + if ( axis.indexOf( 'Y' ) === - 1 ) this._offset.y = 0; + if ( axis.indexOf( 'Z' ) === - 1 ) this._offset.z = 0; + + if ( space === 'local' && axis !== 'XYZ' ) { + + this._offset.applyQuaternion( this._quaternionStart ).divide( this._parentScale ); + + } else { + + this._offset.applyQuaternion( this._parentQuaternionInv ).divide( this._parentScale ); + + } + + object.position.copy( this._offset ).add( this._positionStart ); + + // Apply translation snap + + if ( this.translationSnap ) { + + if ( space === 'local' ) { + + object.position.applyQuaternion( _tempQuaternion.copy( this._quaternionStart ).invert() ); + + if ( axis.search( 'X' ) !== - 1 ) { + + object.position.x = Math.round( object.position.x / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Y' ) !== - 1 ) { + + object.position.y = Math.round( object.position.y / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Z' ) !== - 1 ) { + + object.position.z = Math.round( object.position.z / this.translationSnap ) * this.translationSnap; + + } + + object.position.applyQuaternion( this._quaternionStart ); + + } + + if ( space === 'world' ) { + + if ( object.parent ) { + + object.position.add( _tempVector.setFromMatrixPosition( object.parent.matrixWorld ) ); + + } + + if ( axis.search( 'X' ) !== - 1 ) { + + object.position.x = Math.round( object.position.x / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Y' ) !== - 1 ) { + + object.position.y = Math.round( object.position.y / this.translationSnap ) * this.translationSnap; + + } + + if ( axis.search( 'Z' ) !== - 1 ) { + + object.position.z = Math.round( object.position.z / this.translationSnap ) * this.translationSnap; + + } + + if ( object.parent ) { + + object.position.sub( _tempVector.setFromMatrixPosition( object.parent.matrixWorld ) ); + + } + + } + + } + + } else if ( mode === 'scale' ) { + + if ( axis.search( 'XYZ' ) !== - 1 ) { + + let d = this.pointEnd.length() / this.pointStart.length(); + + if ( this.pointEnd.dot( this.pointStart ) < 0 ) d *= - 1; + + _tempVector2.set( d, d, d ); + + } else { + + _tempVector.copy( this.pointStart ); + _tempVector2.copy( this.pointEnd ); + + _tempVector.applyQuaternion( this._worldQuaternionInv ); + _tempVector2.applyQuaternion( this._worldQuaternionInv ); + + _tempVector2.divide( _tempVector ); + + if ( axis.search( 'X' ) === - 1 ) { + + _tempVector2.x = 1; + + } + + if ( axis.search( 'Y' ) === - 1 ) { + + _tempVector2.y = 1; + + } + + if ( axis.search( 'Z' ) === - 1 ) { + + _tempVector2.z = 1; + + } + + } + + // Apply scale + + object.scale.copy( this._scaleStart ).multiply( _tempVector2 ); + + if ( this.scaleSnap ) { + + if ( axis.search( 'X' ) !== - 1 ) { + + object.scale.x = Math.round( object.scale.x / this.scaleSnap ) * this.scaleSnap || this.scaleSnap; + + } + + if ( axis.search( 'Y' ) !== - 1 ) { + + object.scale.y = Math.round( object.scale.y / this.scaleSnap ) * this.scaleSnap || this.scaleSnap; + + } + + if ( axis.search( 'Z' ) !== - 1 ) { + + object.scale.z = Math.round( object.scale.z / this.scaleSnap ) * this.scaleSnap || this.scaleSnap; + + } + + } + + } else if ( mode === 'rotate' ) { + + this._offset.copy( this.pointEnd ).sub( this.pointStart ); + + const ROTATION_SPEED = 20 / this.worldPosition.distanceTo( _tempVector.setFromMatrixPosition( this.camera.matrixWorld ) ); + + let _inPlaneRotation = false; + + if ( axis === 'XYZE' ) { + + this.rotationAxis.copy( this._offset ).cross( this.eye ).normalize(); + this.rotationAngle = this._offset.dot( _tempVector.copy( this.rotationAxis ).cross( this.eye ) ) * ROTATION_SPEED; + + } else if ( axis === 'X' || axis === 'Y' || axis === 'Z' ) { + + this.rotationAxis.copy( _unit[ axis ] ); + + _tempVector.copy( _unit[ axis ] ); + + if ( space === 'local' ) { + + _tempVector.applyQuaternion( this.worldQuaternion ); + + } + + _tempVector.cross( this.eye ); + + // When _tempVector is 0 after cross with this.eye the vectors are parallel and should use in-plane rotation logic. + if ( _tempVector.length() === 0 ) { + + _inPlaneRotation = true; + + } else { + + this.rotationAngle = this._offset.dot( _tempVector.normalize() ) * ROTATION_SPEED; + + } + + + } + + if ( axis === 'E' || _inPlaneRotation ) { + + this.rotationAxis.copy( this.eye ); + this.rotationAngle = this.pointEnd.angleTo( this.pointStart ); + + this._startNorm.copy( this.pointStart ).normalize(); + this._endNorm.copy( this.pointEnd ).normalize(); + + this.rotationAngle *= ( this._endNorm.cross( this._startNorm ).dot( this.eye ) < 0 ? 1 : - 1 ); + + } + + // Apply rotation snap + + if ( this.rotationSnap ) this.rotationAngle = Math.round( this.rotationAngle / this.rotationSnap ) * this.rotationSnap; + + // Apply rotate + if ( space === 'local' && axis !== 'E' && axis !== 'XYZE' ) { + + object.quaternion.copy( this._quaternionStart ); + object.quaternion.multiply( _tempQuaternion.setFromAxisAngle( this.rotationAxis, this.rotationAngle ) ).normalize(); + + } else { + + this.rotationAxis.applyQuaternion( this._parentQuaternionInv ); + object.quaternion.copy( _tempQuaternion.setFromAxisAngle( this.rotationAxis, this.rotationAngle ) ); + object.quaternion.multiply( this._quaternionStart ).normalize(); + + } + + } + + this.dispatchEvent( _changeEvent ); + this.dispatchEvent( _objectChangeEvent ); + + } + + pointerUp( pointer ) { + + if ( pointer.button !== 0 ) return; + + if ( this.dragging && ( this.axis !== null ) ) { + + _mouseUpEvent.mode = this.mode; + this.dispatchEvent( _mouseUpEvent ); + + } + + this.dragging = false; + this.axis = null; + + } + + dispose() { + + this.domElement.removeEventListener( 'pointerdown', this._onPointerDown ); + this.domElement.removeEventListener( 'pointermove', this._onPointerHover ); + this.domElement.removeEventListener( 'pointermove', this._onPointerMove ); + this.domElement.removeEventListener( 'pointerup', this._onPointerUp ); + + this.traverse( function ( child ) { + + if ( child.geometry ) child.geometry.dispose(); + if ( child.material ) child.material.dispose(); + + } ); + + } + + // Set current object + attach( object ) { + + this.object = object; + this.visible = true; + + return this; + + } + + // Detach from object + detach() { + + this.object = undefined; + this.visible = false; + this.axis = null; + + return this; + + } + + reset() { + + if ( ! this.enabled ) return; + + if ( this.dragging ) { + + this.object.position.copy( this._positionStart ); + this.object.quaternion.copy( this._quaternionStart ); + this.object.scale.copy( this._scaleStart ); + + this.dispatchEvent( _changeEvent ); + this.dispatchEvent( _objectChangeEvent ); + + this.pointStart.copy( this.pointEnd ); + + } + + } + + getRaycaster() { + + return _raycaster; + + } + + // TODO: deprecate + + getMode() { + + return this.mode; + + } + + setMode( mode ) { + + this.mode = mode; + + } + + setTranslationSnap( translationSnap ) { + + this.translationSnap = translationSnap; + + } + + setRotationSnap( rotationSnap ) { + + this.rotationSnap = rotationSnap; + + } + + setScaleSnap( scaleSnap ) { + + this.scaleSnap = scaleSnap; + + } + + setSize( size ) { + + this.size = size; + + } + + setSpace( space ) { + + this.space = space; + + } + +} + +// mouse / touch event handlers + +function getPointer( event ) { + + if ( this.domElement.ownerDocument.pointerLockElement ) { + + return { + x: 0, + y: 0, + button: event.button + }; + + } else { + + const rect = this.domElement.getBoundingClientRect(); + + return { + x: ( event.clientX - rect.left ) / rect.width * 2 - 1, + y: - ( event.clientY - rect.top ) / rect.height * 2 + 1, + button: event.button + }; + + } + +} + +function onPointerHover( event ) { + + if ( ! this.enabled ) return; + + switch ( event.pointerType ) { + + case 'mouse': + case 'pen': + this.pointerHover( this._getPointer( event ) ); + break; + + } + +} + +function onPointerDown( event ) { + + if ( ! this.enabled ) return; + + if ( ! document.pointerLockElement ) { + + this.domElement.setPointerCapture( event.pointerId ); + + } + + this.domElement.addEventListener( 'pointermove', this._onPointerMove ); + + this.pointerHover( this._getPointer( event ) ); + this.pointerDown( this._getPointer( event ) ); + +} + +function onPointerMove( event ) { + + if ( ! this.enabled ) return; + + this.pointerMove( this._getPointer( event ) ); + +} + +function onPointerUp( event ) { + + if ( ! this.enabled ) return; + + this.domElement.releasePointerCapture( event.pointerId ); + + this.domElement.removeEventListener( 'pointermove', this._onPointerMove ); + + this.pointerUp( this._getPointer( event ) ); + +} + +function intersectObjectWithRay( object, raycaster, includeInvisible ) { + + const allIntersections = raycaster.intersectObject( object, true ); + + for ( let i = 0; i < allIntersections.length; i ++ ) { + + if ( allIntersections[ i ].object.visible || includeInvisible ) { + + return allIntersections[ i ]; + + } + + } + + return false; + +} + +// + +// Reusable utility variables + +const _tempEuler = new Euler(); +const _alignVector = new Vector3( 0, 1, 0 ); +const _zeroVector = new Vector3( 0, 0, 0 ); +const _lookAtMatrix = new Matrix4(); +const _tempQuaternion2 = new Quaternion(); +const _identityQuaternion = new Quaternion(); +const _dirVector = new Vector3(); +const _tempMatrix = new Matrix4(); + +const _unitX = new Vector3( 1, 0, 0 ); +const _unitY = new Vector3( 0, 1, 0 ); +const _unitZ = new Vector3( 0, 0, 1 ); + +const _v1 = new Vector3(); +const _v2 = new Vector3(); +const _v3 = new Vector3(); + +class TransformControlsGizmo extends Object3D { + + constructor() { + + super(); + + this.isTransformControlsGizmo = true; + + this.type = 'TransformControlsGizmo'; + + // shared materials + + const gizmoMaterial = new MeshBasicMaterial( { + depthTest: false, + depthWrite: false, + fog: false, + toneMapped: false, + transparent: true + } ); + + const gizmoLineMaterial = new LineBasicMaterial( { + depthTest: false, + depthWrite: false, + fog: false, + toneMapped: false, + transparent: true + } ); + + // Make unique material for each axis/color + + const matInvisible = gizmoMaterial.clone(); + matInvisible.opacity = 0.15; + + const matHelper = gizmoLineMaterial.clone(); + matHelper.opacity = 0.5; + + const matRed = gizmoMaterial.clone(); + matRed.color.setHex( 0xff0000 ); + + const matGreen = gizmoMaterial.clone(); + matGreen.color.setHex( 0x00ff00 ); + + const matBlue = gizmoMaterial.clone(); + matBlue.color.setHex( 0x0000ff ); + + const matRedTransparent = gizmoMaterial.clone(); + matRedTransparent.color.setHex( 0xff0000 ); + matRedTransparent.opacity = 0.5; + + const matGreenTransparent = gizmoMaterial.clone(); + matGreenTransparent.color.setHex( 0x00ff00 ); + matGreenTransparent.opacity = 0.5; + + const matBlueTransparent = gizmoMaterial.clone(); + matBlueTransparent.color.setHex( 0x0000ff ); + matBlueTransparent.opacity = 0.5; + + const matWhiteTransparent = gizmoMaterial.clone(); + matWhiteTransparent.opacity = 0.25; + + const matYellowTransparent = gizmoMaterial.clone(); + matYellowTransparent.color.setHex( 0xffff00 ); + matYellowTransparent.opacity = 0.25; + + const matYellow = gizmoMaterial.clone(); + matYellow.color.setHex( 0xffff00 ); + + const matGray = gizmoMaterial.clone(); + matGray.color.setHex( 0x787878 ); + + // reusable geometry + + const arrowGeometry = new CylinderGeometry( 0, 0.04, 0.1, 12 ); + arrowGeometry.translate( 0, 0.05, 0 ); + + const scaleHandleGeometry = new BoxGeometry( 0.08, 0.08, 0.08 ); + scaleHandleGeometry.translate( 0, 0.04, 0 ); + + const lineGeometry = new BufferGeometry(); + lineGeometry.setAttribute( 'position', new Float32BufferAttribute( [ 0, 0, 0, 1, 0, 0 ], 3 ) ); + + const lineGeometry2 = new CylinderGeometry( 0.0075, 0.0075, 0.5, 3 ); + lineGeometry2.translate( 0, 0.25, 0 ); + + function CircleGeometry( radius, arc ) { + + const geometry = new TorusGeometry( radius, 0.0075, 3, 64, arc * Math.PI * 2 ); + geometry.rotateY( Math.PI / 2 ); + geometry.rotateX( Math.PI / 2 ); + return geometry; + + } + + // Special geometry for transform helper. If scaled with position vector it spans from [0,0,0] to position + + function TranslateHelperGeometry() { + + const geometry = new BufferGeometry(); + + geometry.setAttribute( 'position', new Float32BufferAttribute( [ 0, 0, 0, 1, 1, 1 ], 3 ) ); + + return geometry; + + } + + // Gizmo definitions - custom hierarchy definitions for setupGizmo() function + + const gizmoTranslate = { + X: [ + [ new Mesh( arrowGeometry, matRed ), [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], + [ new Mesh( arrowGeometry, matRed ), [ - 0.5, 0, 0 ], [ 0, 0, Math.PI / 2 ]], + [ new Mesh( lineGeometry2, matRed ), [ 0, 0, 0 ], [ 0, 0, - Math.PI / 2 ]] + ], + Y: [ + [ new Mesh( arrowGeometry, matGreen ), [ 0, 0.5, 0 ]], + [ new Mesh( arrowGeometry, matGreen ), [ 0, - 0.5, 0 ], [ Math.PI, 0, 0 ]], + [ new Mesh( lineGeometry2, matGreen ) ] + ], + Z: [ + [ new Mesh( arrowGeometry, matBlue ), [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ]], + [ new Mesh( arrowGeometry, matBlue ), [ 0, 0, - 0.5 ], [ - Math.PI / 2, 0, 0 ]], + [ new Mesh( lineGeometry2, matBlue ), null, [ Math.PI / 2, 0, 0 ]] + ], + XYZ: [ + [ new Mesh( new OctahedronGeometry( 0.1, 0 ), matWhiteTransparent.clone() ), [ 0, 0, 0 ]] + ], + XY: [ + [ new Mesh( new BoxGeometry( 0.15, 0.15, 0.01 ), matBlueTransparent.clone() ), [ 0.15, 0.15, 0 ]] + ], + YZ: [ + [ new Mesh( new BoxGeometry( 0.15, 0.15, 0.01 ), matRedTransparent.clone() ), [ 0, 0.15, 0.15 ], [ 0, Math.PI / 2, 0 ]] + ], + XZ: [ + [ new Mesh( new BoxGeometry( 0.15, 0.15, 0.01 ), matGreenTransparent.clone() ), [ 0.15, 0, 0.15 ], [ - Math.PI / 2, 0, 0 ]] + ] + }; + + const pickerTranslate = { + X: [ + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0.3, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ - 0.3, 0, 0 ], [ 0, 0, Math.PI / 2 ]] + ], + Y: [ + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, 0.3, 0 ]], + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, - 0.3, 0 ], [ 0, 0, Math.PI ]] + ], + Z: [ + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, 0, 0.3 ], [ Math.PI / 2, 0, 0 ]], + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, 0, - 0.3 ], [ - Math.PI / 2, 0, 0 ]] + ], + XYZ: [ + [ new Mesh( new OctahedronGeometry( 0.2, 0 ), matInvisible ) ] + ], + XY: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.01 ), matInvisible ), [ 0.15, 0.15, 0 ]] + ], + YZ: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.01 ), matInvisible ), [ 0, 0.15, 0.15 ], [ 0, Math.PI / 2, 0 ]] + ], + XZ: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.01 ), matInvisible ), [ 0.15, 0, 0.15 ], [ - Math.PI / 2, 0, 0 ]] + ] + }; + + const helperTranslate = { + START: [ + [ new Mesh( new OctahedronGeometry( 0.01, 2 ), matHelper ), null, null, null, 'helper' ] + ], + END: [ + [ new Mesh( new OctahedronGeometry( 0.01, 2 ), matHelper ), null, null, null, 'helper' ] + ], + DELTA: [ + [ new Line( TranslateHelperGeometry(), matHelper ), null, null, null, 'helper' ] + ], + X: [ + [ new Line( lineGeometry, matHelper.clone() ), [ - 1e3, 0, 0 ], null, [ 1e6, 1, 1 ], 'helper' ] + ], + Y: [ + [ new Line( lineGeometry, matHelper.clone() ), [ 0, - 1e3, 0 ], [ 0, 0, Math.PI / 2 ], [ 1e6, 1, 1 ], 'helper' ] + ], + Z: [ + [ new Line( lineGeometry, matHelper.clone() ), [ 0, 0, - 1e3 ], [ 0, - Math.PI / 2, 0 ], [ 1e6, 1, 1 ], 'helper' ] + ] + }; + + const gizmoRotate = { + XYZE: [ + [ new Mesh( CircleGeometry( 0.5, 1 ), matGray ), null, [ 0, Math.PI / 2, 0 ]] + ], + X: [ + [ new Mesh( CircleGeometry( 0.5, 0.5 ), matRed ) ] + ], + Y: [ + [ new Mesh( CircleGeometry( 0.5, 0.5 ), matGreen ), null, [ 0, 0, - Math.PI / 2 ]] + ], + Z: [ + [ new Mesh( CircleGeometry( 0.5, 0.5 ), matBlue ), null, [ 0, Math.PI / 2, 0 ]] + ], + E: [ + [ new Mesh( CircleGeometry( 0.75, 1 ), matYellowTransparent ), null, [ 0, Math.PI / 2, 0 ]] + ] + }; + + const helperRotate = { + AXIS: [ + [ new Line( lineGeometry, matHelper.clone() ), [ - 1e3, 0, 0 ], null, [ 1e6, 1, 1 ], 'helper' ] + ] + }; + + const pickerRotate = { + XYZE: [ + [ new Mesh( new SphereGeometry( 0.25, 10, 8 ), matInvisible ) ] + ], + X: [ + [ new Mesh( new TorusGeometry( 0.5, 0.1, 4, 24 ), matInvisible ), [ 0, 0, 0 ], [ 0, - Math.PI / 2, - Math.PI / 2 ]], + ], + Y: [ + [ new Mesh( new TorusGeometry( 0.5, 0.1, 4, 24 ), matInvisible ), [ 0, 0, 0 ], [ Math.PI / 2, 0, 0 ]], + ], + Z: [ + [ new Mesh( new TorusGeometry( 0.5, 0.1, 4, 24 ), matInvisible ), [ 0, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], + ], + E: [ + [ new Mesh( new TorusGeometry( 0.75, 0.1, 2, 24 ), matInvisible ) ] + ] + }; + + const gizmoScale = { + X: [ + [ new Mesh( scaleHandleGeometry, matRed ), [ 0.5, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], + [ new Mesh( lineGeometry2, matRed ), [ 0, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], + [ new Mesh( scaleHandleGeometry, matRed ), [ - 0.5, 0, 0 ], [ 0, 0, Math.PI / 2 ]], + ], + Y: [ + [ new Mesh( scaleHandleGeometry, matGreen ), [ 0, 0.5, 0 ]], + [ new Mesh( lineGeometry2, matGreen ) ], + [ new Mesh( scaleHandleGeometry, matGreen ), [ 0, - 0.5, 0 ], [ 0, 0, Math.PI ]], + ], + Z: [ + [ new Mesh( scaleHandleGeometry, matBlue ), [ 0, 0, 0.5 ], [ Math.PI / 2, 0, 0 ]], + [ new Mesh( lineGeometry2, matBlue ), [ 0, 0, 0 ], [ Math.PI / 2, 0, 0 ]], + [ new Mesh( scaleHandleGeometry, matBlue ), [ 0, 0, - 0.5 ], [ - Math.PI / 2, 0, 0 ]] + ], + XY: [ + [ new Mesh( new BoxGeometry( 0.15, 0.15, 0.01 ), matBlueTransparent ), [ 0.15, 0.15, 0 ]] + ], + YZ: [ + [ new Mesh( new BoxGeometry( 0.15, 0.15, 0.01 ), matRedTransparent ), [ 0, 0.15, 0.15 ], [ 0, Math.PI / 2, 0 ]] + ], + XZ: [ + [ new Mesh( new BoxGeometry( 0.15, 0.15, 0.01 ), matGreenTransparent ), [ 0.15, 0, 0.15 ], [ - Math.PI / 2, 0, 0 ]] + ], + XYZ: [ + [ new Mesh( new BoxGeometry( 0.1, 0.1, 0.1 ), matWhiteTransparent.clone() ) ], + ] + }; + + const pickerScale = { + X: [ + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0.3, 0, 0 ], [ 0, 0, - Math.PI / 2 ]], + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ - 0.3, 0, 0 ], [ 0, 0, Math.PI / 2 ]] + ], + Y: [ + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, 0.3, 0 ]], + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, - 0.3, 0 ], [ 0, 0, Math.PI ]] + ], + Z: [ + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, 0, 0.3 ], [ Math.PI / 2, 0, 0 ]], + [ new Mesh( new CylinderGeometry( 0.2, 0, 0.6, 4 ), matInvisible ), [ 0, 0, - 0.3 ], [ - Math.PI / 2, 0, 0 ]] + ], + XY: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.01 ), matInvisible ), [ 0.15, 0.15, 0 ]], + ], + YZ: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.01 ), matInvisible ), [ 0, 0.15, 0.15 ], [ 0, Math.PI / 2, 0 ]], + ], + XZ: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.01 ), matInvisible ), [ 0.15, 0, 0.15 ], [ - Math.PI / 2, 0, 0 ]], + ], + XYZ: [ + [ new Mesh( new BoxGeometry( 0.2, 0.2, 0.2 ), matInvisible ), [ 0, 0, 0 ]], + ] + }; + + const helperScale = { + X: [ + [ new Line( lineGeometry, matHelper.clone() ), [ - 1e3, 0, 0 ], null, [ 1e6, 1, 1 ], 'helper' ] + ], + Y: [ + [ new Line( lineGeometry, matHelper.clone() ), [ 0, - 1e3, 0 ], [ 0, 0, Math.PI / 2 ], [ 1e6, 1, 1 ], 'helper' ] + ], + Z: [ + [ new Line( lineGeometry, matHelper.clone() ), [ 0, 0, - 1e3 ], [ 0, - Math.PI / 2, 0 ], [ 1e6, 1, 1 ], 'helper' ] + ] + }; + + // Creates an Object3D with gizmos described in custom hierarchy definition. + + function setupGizmo( gizmoMap ) { + + const gizmo = new Object3D(); + + for ( const name in gizmoMap ) { + + for ( let i = gizmoMap[ name ].length; i --; ) { + + const object = gizmoMap[ name ][ i ][ 0 ].clone(); + const position = gizmoMap[ name ][ i ][ 1 ]; + const rotation = gizmoMap[ name ][ i ][ 2 ]; + const scale = gizmoMap[ name ][ i ][ 3 ]; + const tag = gizmoMap[ name ][ i ][ 4 ]; + + // name and tag properties are essential for picking and updating logic. + object.name = name; + object.tag = tag; + + if ( position ) { + + object.position.set( position[ 0 ], position[ 1 ], position[ 2 ] ); + + } + + if ( rotation ) { + + object.rotation.set( rotation[ 0 ], rotation[ 1 ], rotation[ 2 ] ); + + } + + if ( scale ) { + + object.scale.set( scale[ 0 ], scale[ 1 ], scale[ 2 ] ); + + } + + object.updateMatrix(); + + const tempGeometry = object.geometry.clone(); + tempGeometry.applyMatrix4( object.matrix ); + object.geometry = tempGeometry; + object.renderOrder = Infinity; + + object.position.set( 0, 0, 0 ); + object.rotation.set( 0, 0, 0 ); + object.scale.set( 1, 1, 1 ); + + gizmo.add( object ); + + } + + } + + return gizmo; + + } + + // Gizmo creation + + this.gizmo = {}; + this.picker = {}; + this.helper = {}; + + this.add( this.gizmo[ 'translate' ] = setupGizmo( gizmoTranslate ) ); + this.add( this.gizmo[ 'rotate' ] = setupGizmo( gizmoRotate ) ); + this.add( this.gizmo[ 'scale' ] = setupGizmo( gizmoScale ) ); + this.add( this.picker[ 'translate' ] = setupGizmo( pickerTranslate ) ); + this.add( this.picker[ 'rotate' ] = setupGizmo( pickerRotate ) ); + this.add( this.picker[ 'scale' ] = setupGizmo( pickerScale ) ); + this.add( this.helper[ 'translate' ] = setupGizmo( helperTranslate ) ); + this.add( this.helper[ 'rotate' ] = setupGizmo( helperRotate ) ); + this.add( this.helper[ 'scale' ] = setupGizmo( helperScale ) ); + + // Pickers should be hidden always + + this.picker[ 'translate' ].visible = false; + this.picker[ 'rotate' ].visible = false; + this.picker[ 'scale' ].visible = false; + + } + + // updateMatrixWorld will update transformations and appearance of individual handles + + updateMatrixWorld( force ) { + + const space = ( this.mode === 'scale' ) ? 'local' : this.space; // scale always oriented to local rotation + + const quaternion = ( space === 'local' ) ? this.worldQuaternion : _identityQuaternion; + + // Show only gizmos for current transform mode + + this.gizmo[ 'translate' ].visible = this.mode === 'translate'; + this.gizmo[ 'rotate' ].visible = this.mode === 'rotate'; + this.gizmo[ 'scale' ].visible = this.mode === 'scale'; + + this.helper[ 'translate' ].visible = this.mode === 'translate'; + this.helper[ 'rotate' ].visible = this.mode === 'rotate'; + this.helper[ 'scale' ].visible = this.mode === 'scale'; + + + let handles = []; + handles = handles.concat( this.picker[ this.mode ].children ); + handles = handles.concat( this.gizmo[ this.mode ].children ); + handles = handles.concat( this.helper[ this.mode ].children ); + + for ( let i = 0; i < handles.length; i ++ ) { + + const handle = handles[ i ]; + + // hide aligned to camera + + handle.visible = true; + handle.rotation.set( 0, 0, 0 ); + handle.position.copy( this.worldPosition ); + + let factor; + + if ( this.camera.isOrthographicCamera ) { + + factor = ( this.camera.top - this.camera.bottom ) / this.camera.zoom; + + } else { + + factor = this.worldPosition.distanceTo( this.cameraPosition ) * Math.min( 1.9 * Math.tan( Math.PI * this.camera.fov / 360 ) / this.camera.zoom, 7 ); + + } + + handle.scale.set( 1, 1, 1 ).multiplyScalar( factor * this.size / 4 ); + + // TODO: simplify helpers and consider decoupling from gizmo + + if ( handle.tag === 'helper' ) { + + handle.visible = false; + + if ( handle.name === 'AXIS' ) { + + handle.visible = !! this.axis; + + if ( this.axis === 'X' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, 0, 0 ) ); + handle.quaternion.copy( quaternion ).multiply( _tempQuaternion ); + + if ( Math.abs( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) ) > 0.9 ) { + + handle.visible = false; + + } + + } + + if ( this.axis === 'Y' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, 0, Math.PI / 2 ) ); + handle.quaternion.copy( quaternion ).multiply( _tempQuaternion ); + + if ( Math.abs( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) ) > 0.9 ) { + + handle.visible = false; + + } + + } + + if ( this.axis === 'Z' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, Math.PI / 2, 0 ) ); + handle.quaternion.copy( quaternion ).multiply( _tempQuaternion ); + + if ( Math.abs( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) ) > 0.9 ) { + + handle.visible = false; + + } + + } + + if ( this.axis === 'XYZE' ) { + + _tempQuaternion.setFromEuler( _tempEuler.set( 0, Math.PI / 2, 0 ) ); + _alignVector.copy( this.rotationAxis ); + handle.quaternion.setFromRotationMatrix( _lookAtMatrix.lookAt( _zeroVector, _alignVector, _unitY ) ); + handle.quaternion.multiply( _tempQuaternion ); + handle.visible = this.dragging; + + } + + if ( this.axis === 'E' ) { + + handle.visible = false; + + } + + + } else if ( handle.name === 'START' ) { + + handle.position.copy( this.worldPositionStart ); + handle.visible = this.dragging; + + } else if ( handle.name === 'END' ) { + + handle.position.copy( this.worldPosition ); + handle.visible = this.dragging; + + } else if ( handle.name === 'DELTA' ) { + + handle.position.copy( this.worldPositionStart ); + handle.quaternion.copy( this.worldQuaternionStart ); + _tempVector.set( 1e-10, 1e-10, 1e-10 ).add( this.worldPositionStart ).sub( this.worldPosition ).multiplyScalar( - 1 ); + _tempVector.applyQuaternion( this.worldQuaternionStart.clone().invert() ); + handle.scale.copy( _tempVector ); + handle.visible = this.dragging; + + } else { + + handle.quaternion.copy( quaternion ); + + if ( this.dragging ) { + + handle.position.copy( this.worldPositionStart ); + + } else { + + handle.position.copy( this.worldPosition ); + + } + + if ( this.axis ) { + + handle.visible = this.axis.search( handle.name ) !== - 1; + + } + + } + + // If updating helper, skip rest of the loop + continue; + + } + + // Align handles to current local or world rotation + + handle.quaternion.copy( quaternion ); + + if ( this.mode === 'translate' || this.mode === 'scale' ) { + + // Hide translate and scale axis facing the camera + + const AXIS_HIDE_THRESHOLD = 0.99; + const PLANE_HIDE_THRESHOLD = 0.2; + + if ( handle.name === 'X' ) { + + if ( Math.abs( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) ) > AXIS_HIDE_THRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'Y' ) { + + if ( Math.abs( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) ) > AXIS_HIDE_THRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'Z' ) { + + if ( Math.abs( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) ) > AXIS_HIDE_THRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'XY' ) { + + if ( Math.abs( _alignVector.copy( _unitZ ).applyQuaternion( quaternion ).dot( this.eye ) ) < PLANE_HIDE_THRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'YZ' ) { + + if ( Math.abs( _alignVector.copy( _unitX ).applyQuaternion( quaternion ).dot( this.eye ) ) < PLANE_HIDE_THRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + if ( handle.name === 'XZ' ) { + + if ( Math.abs( _alignVector.copy( _unitY ).applyQuaternion( quaternion ).dot( this.eye ) ) < PLANE_HIDE_THRESHOLD ) { + + handle.scale.set( 1e-10, 1e-10, 1e-10 ); + handle.visible = false; + + } + + } + + } else if ( this.mode === 'rotate' ) { + + // Align handles to current local or world rotation + + _tempQuaternion2.copy( quaternion ); + _alignVector.copy( this.eye ).applyQuaternion( _tempQuaternion.copy( quaternion ).invert() ); + + if ( handle.name.search( 'E' ) !== - 1 ) { + + handle.quaternion.setFromRotationMatrix( _lookAtMatrix.lookAt( this.eye, _zeroVector, _unitY ) ); + + } + + if ( handle.name === 'X' ) { + + _tempQuaternion.setFromAxisAngle( _unitX, Math.atan2( - _alignVector.y, _alignVector.z ) ); + _tempQuaternion.multiplyQuaternions( _tempQuaternion2, _tempQuaternion ); + handle.quaternion.copy( _tempQuaternion ); + + } + + if ( handle.name === 'Y' ) { + + _tempQuaternion.setFromAxisAngle( _unitY, Math.atan2( _alignVector.x, _alignVector.z ) ); + _tempQuaternion.multiplyQuaternions( _tempQuaternion2, _tempQuaternion ); + handle.quaternion.copy( _tempQuaternion ); + + } + + if ( handle.name === 'Z' ) { + + _tempQuaternion.setFromAxisAngle( _unitZ, Math.atan2( _alignVector.y, _alignVector.x ) ); + _tempQuaternion.multiplyQuaternions( _tempQuaternion2, _tempQuaternion ); + handle.quaternion.copy( _tempQuaternion ); + + } + + } + + // Hide disabled axes + handle.visible = handle.visible && ( handle.name.indexOf( 'X' ) === - 1 || this.showX ); + handle.visible = handle.visible && ( handle.name.indexOf( 'Y' ) === - 1 || this.showY ); + handle.visible = handle.visible && ( handle.name.indexOf( 'Z' ) === - 1 || this.showZ ); + handle.visible = handle.visible && ( handle.name.indexOf( 'E' ) === - 1 || ( this.showX && this.showY && this.showZ ) ); + + // highlight selected axis + + handle.material._color = handle.material._color || handle.material.color.clone(); + handle.material._opacity = handle.material._opacity || handle.material.opacity; + + handle.material.color.copy( handle.material._color ); + handle.material.opacity = handle.material._opacity; + + if ( this.enabled && this.axis ) { + + if ( handle.name === this.axis ) { + + handle.material.color.setHex( 0xffff00 ); + handle.material.opacity = 1.0; + + } else if ( this.axis.split( '' ).some( function ( a ) { + + return handle.name === a; + + } ) ) { + + handle.material.color.setHex( 0xffff00 ); + handle.material.opacity = 1.0; + + } + + } + + } + + super.updateMatrixWorld( force ); + + } + +} + +// + +class TransformControlsPlane extends Mesh { + + constructor() { + + super( + new PlaneGeometry( 100000, 100000, 2, 2 ), + new MeshBasicMaterial( { visible: false, wireframe: true, side: DoubleSide, transparent: true, opacity: 0.1, toneMapped: false } ) + ); + + this.isTransformControlsPlane = true; + + this.type = 'TransformControlsPlane'; + + } + + updateMatrixWorld( force ) { + + let space = this.space; + + this.position.copy( this.worldPosition ); + + if ( this.mode === 'scale' ) space = 'local'; // scale always oriented to local rotation + + _v1.copy( _unitX ).applyQuaternion( space === 'local' ? this.worldQuaternion : _identityQuaternion ); + _v2.copy( _unitY ).applyQuaternion( space === 'local' ? this.worldQuaternion : _identityQuaternion ); + _v3.copy( _unitZ ).applyQuaternion( space === 'local' ? this.worldQuaternion : _identityQuaternion ); + + // Align the plane for current transform mode, axis and space. + + _alignVector.copy( _v2 ); + + switch ( this.mode ) { + + case 'translate': + case 'scale': + switch ( this.axis ) { + + case 'X': + _alignVector.copy( this.eye ).cross( _v1 ); + _dirVector.copy( _v1 ).cross( _alignVector ); + break; + case 'Y': + _alignVector.copy( this.eye ).cross( _v2 ); + _dirVector.copy( _v2 ).cross( _alignVector ); + break; + case 'Z': + _alignVector.copy( this.eye ).cross( _v3 ); + _dirVector.copy( _v3 ).cross( _alignVector ); + break; + case 'XY': + _dirVector.copy( _v3 ); + break; + case 'YZ': + _dirVector.copy( _v1 ); + break; + case 'XZ': + _alignVector.copy( _v3 ); + _dirVector.copy( _v2 ); + break; + case 'XYZ': + case 'E': + _dirVector.set( 0, 0, 0 ); + break; + + } + + break; + case 'rotate': + default: + // special case for rotate + _dirVector.set( 0, 0, 0 ); + + } + + if ( _dirVector.length() === 0 ) { + + // If in rotate mode, make the plane parallel to camera + this.quaternion.copy( this.cameraQuaternion ); + + } else { + + _tempMatrix.lookAt( _tempVector.set( 0, 0, 0 ), _dirVector, _alignVector ); + + this.quaternion.setFromRotationMatrix( _tempMatrix ); + + } + + super.updateMatrixWorld( force ); + + } + +} + +export { TransformControls, TransformControlsGizmo, TransformControlsPlane }; \ No newline at end of file diff --git a/SharedLibs/ThreeAddons/src/examples/jsm/interactive/SelectionBox.js b/SharedLibs/ThreeAddons/src/examples/jsm/interactive/SelectionBox.js new file mode 100644 index 0000000000..597b9b2f10 --- /dev/null +++ b/SharedLibs/ThreeAddons/src/examples/jsm/interactive/SelectionBox.js @@ -0,0 +1,227 @@ +import { + Frustum, + Vector3, + Matrix4, + Quaternion, +} from 'three'; + +/** + * This is a class to check whether objects are in a selection area in 3D space + */ + +const _frustum = new Frustum(); +const _center = new Vector3(); + +const _tmpPoint = new Vector3(); + +const _vecNear = new Vector3(); +const _vecTopLeft = new Vector3(); +const _vecTopRight = new Vector3(); +const _vecDownRight = new Vector3(); +const _vecDownLeft = new Vector3(); + +const _vecFarTopLeft = new Vector3(); +const _vecFarTopRight = new Vector3(); +const _vecFarDownRight = new Vector3(); +const _vecFarDownLeft = new Vector3(); + +const _vectemp1 = new Vector3(); +const _vectemp2 = new Vector3(); +const _vectemp3 = new Vector3(); + +const _matrix = new Matrix4(); +const _quaternion = new Quaternion(); +const _scale = new Vector3(); + +class SelectionBox { + + constructor( camera, scene, deep = Number.MAX_VALUE ) { + + this.camera = camera; + this.scene = scene; + this.startPoint = new Vector3(); + this.endPoint = new Vector3(); + this.collection = []; + this.instances = {}; + this.deep = deep; + + } + + select( startPoint, endPoint ) { + + this.startPoint = startPoint || this.startPoint; + this.endPoint = endPoint || this.endPoint; + this.collection = []; + + this.updateFrustum( this.startPoint, this.endPoint ); + this.searchChildInFrustum( _frustum, this.scene ); + + return this.collection; + + } + + updateFrustum( startPoint, endPoint ) { + + startPoint = startPoint || this.startPoint; + endPoint = endPoint || this.endPoint; + + // Avoid invalid frustum + + if ( startPoint.x === endPoint.x ) { + + endPoint.x += Number.EPSILON; + + } + + if ( startPoint.y === endPoint.y ) { + + endPoint.y += Number.EPSILON; + + } + + this.camera.updateProjectionMatrix(); + this.camera.updateMatrixWorld(); + + if ( this.camera.isPerspectiveCamera ) { + + _tmpPoint.copy( startPoint ); + _tmpPoint.x = Math.min( startPoint.x, endPoint.x ); + _tmpPoint.y = Math.max( startPoint.y, endPoint.y ); + endPoint.x = Math.max( startPoint.x, endPoint.x ); + endPoint.y = Math.min( startPoint.y, endPoint.y ); + + _vecNear.setFromMatrixPosition( this.camera.matrixWorld ); + _vecTopLeft.copy( _tmpPoint ); + _vecTopRight.set( endPoint.x, _tmpPoint.y, 0 ); + _vecDownRight.copy( endPoint ); + _vecDownLeft.set( _tmpPoint.x, endPoint.y, 0 ); + + _vecTopLeft.unproject( this.camera ); + _vecTopRight.unproject( this.camera ); + _vecDownRight.unproject( this.camera ); + _vecDownLeft.unproject( this.camera ); + + _vectemp1.copy( _vecTopLeft ).sub( _vecNear ); + _vectemp2.copy( _vecTopRight ).sub( _vecNear ); + _vectemp3.copy( _vecDownRight ).sub( _vecNear ); + _vectemp1.normalize(); + _vectemp2.normalize(); + _vectemp3.normalize(); + + _vectemp1.multiplyScalar( this.deep ); + _vectemp2.multiplyScalar( this.deep ); + _vectemp3.multiplyScalar( this.deep ); + _vectemp1.add( _vecNear ); + _vectemp2.add( _vecNear ); + _vectemp3.add( _vecNear ); + + const planes = _frustum.planes; + + planes[ 0 ].setFromCoplanarPoints( _vecNear, _vecTopLeft, _vecTopRight ); + planes[ 1 ].setFromCoplanarPoints( _vecNear, _vecTopRight, _vecDownRight ); + planes[ 2 ].setFromCoplanarPoints( _vecDownRight, _vecDownLeft, _vecNear ); + planes[ 3 ].setFromCoplanarPoints( _vecDownLeft, _vecTopLeft, _vecNear ); + planes[ 4 ].setFromCoplanarPoints( _vecTopRight, _vecDownRight, _vecDownLeft ); + planes[ 5 ].setFromCoplanarPoints( _vectemp3, _vectemp2, _vectemp1 ); + planes[ 5 ].normal.multiplyScalar( - 1 ); + + } else if ( this.camera.isOrthographicCamera ) { + + const left = Math.min( startPoint.x, endPoint.x ); + const top = Math.max( startPoint.y, endPoint.y ); + const right = Math.max( startPoint.x, endPoint.x ); + const down = Math.min( startPoint.y, endPoint.y ); + + _vecTopLeft.set( left, top, - 1 ); + _vecTopRight.set( right, top, - 1 ); + _vecDownRight.set( right, down, - 1 ); + _vecDownLeft.set( left, down, - 1 ); + + _vecFarTopLeft.set( left, top, 1 ); + _vecFarTopRight.set( right, top, 1 ); + _vecFarDownRight.set( right, down, 1 ); + _vecFarDownLeft.set( left, down, 1 ); + + _vecTopLeft.unproject( this.camera ); + _vecTopRight.unproject( this.camera ); + _vecDownRight.unproject( this.camera ); + _vecDownLeft.unproject( this.camera ); + + _vecFarTopLeft.unproject( this.camera ); + _vecFarTopRight.unproject( this.camera ); + _vecFarDownRight.unproject( this.camera ); + _vecFarDownLeft.unproject( this.camera ); + + const planes = _frustum.planes; + + planes[ 0 ].setFromCoplanarPoints( _vecTopLeft, _vecFarTopLeft, _vecFarTopRight ); + planes[ 1 ].setFromCoplanarPoints( _vecTopRight, _vecFarTopRight, _vecFarDownRight ); + planes[ 2 ].setFromCoplanarPoints( _vecFarDownRight, _vecFarDownLeft, _vecDownLeft ); + planes[ 3 ].setFromCoplanarPoints( _vecFarDownLeft, _vecFarTopLeft, _vecTopLeft ); + planes[ 4 ].setFromCoplanarPoints( _vecTopRight, _vecDownRight, _vecDownLeft ); + planes[ 5 ].setFromCoplanarPoints( _vecFarDownRight, _vecFarTopRight, _vecFarTopLeft ); + planes[ 5 ].normal.multiplyScalar( - 1 ); + + } else { + + console.error( 'THREE.SelectionBox: Unsupported camera type.' ); + + } + + } + + searchChildInFrustum( frustum, object ) { + + if ( object.isMesh || object.isLine || object.isPoints ) { + + if ( object.isInstancedMesh ) { + + this.instances[ object.uuid ] = []; + + for ( let instanceId = 0; instanceId < object.count; instanceId ++ ) { + + object.getMatrixAt( instanceId, _matrix ); + _matrix.decompose( _center, _quaternion, _scale ); + _center.applyMatrix4( object.matrixWorld ); + + if ( frustum.containsPoint( _center ) ) { + + this.instances[ object.uuid ].push( instanceId ); + + } + + } + + } else { + + if ( object.geometry.boundingSphere === null ) object.geometry.computeBoundingSphere(); + + _center.copy( object.geometry.boundingSphere.center ); + + _center.applyMatrix4( object.matrixWorld ); + + if ( frustum.containsPoint( _center ) ) { + + this.collection.push( object ); + + } + + } + + } + + if ( object.children.length > 0 ) { + + for ( let x = 0; x < object.children.length; x ++ ) { + + this.searchChildInFrustum( frustum, object.children[ x ] ); + + } + + } + + } + +} + +export { SelectionBox }; diff --git a/SharedLibs/ThreeAddons/src/examples/jsm/postprocessing/OutlinePass.js b/SharedLibs/ThreeAddons/src/examples/jsm/postprocessing/OutlinePass.js new file mode 100644 index 0000000000..3c444bc2f0 --- /dev/null +++ b/SharedLibs/ThreeAddons/src/examples/jsm/postprocessing/OutlinePass.js @@ -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 || object.isTransformControls ) { + + // 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 + #include + + varying vec4 projTexCoord; + varying vec4 vPosition; + uniform mat4 textureMatrix; + + void main() { + + #include + #include + #include + #include + #include + + vPosition = mvPosition; + + vec4 worldPosition = vec4( transformed, 1.0 ); + + #ifdef USE_INSTANCING + + worldPosition = instanceMatrix * worldPosition; + + #endif + + worldPosition = modelMatrix * worldPosition; + + projTexCoord = textureMatrix * worldPosition; + + }`, + + fragmentShader: + `#include + 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 + 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 }; \ No newline at end of file diff --git a/SharedLibs/ThreeAddons/src/index.ts b/SharedLibs/ThreeAddons/src/index.ts index 42a029c1be..b772a43e2e 100644 --- a/SharedLibs/ThreeAddons/src/index.ts +++ b/SharedLibs/ThreeAddons/src/index.ts @@ -5,14 +5,20 @@ 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 { SelectionBox } from "./examples/jsm/interactive/SelectionBox"; + 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"; diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index 86179f4b33..efb715b563 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -50,6 +50,10 @@ import UrlStorageProvider from '../ProjectsStorage/UrlStorageProvider'; import { prepareAiUserContent } from './PrepareAiUserContent'; import { AiRequestContext } from './AiRequestContext'; import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration'; +import { + setEditorHotReloadNeeded, + type HotReloadSteps, +} from '../EmbeddedGame/EmbeddedGameFrame'; import { type CreateProjectResult } from '../Utils/UseCreateProject'; import { SubscriptionSuggestionContext } from '../Profile/Subscription/SubscriptionSuggestionContext'; import { @@ -125,6 +129,12 @@ type Props = {| changes: ObjectGroupsOutsideEditorChanges ) => void, onExtensionInstalled: (extensionNames: Array) => void, + onOpenAskAi: ({| + mode: 'chat' | 'agent', + aiRequestId: string | null, + paneIdentifier: 'left' | 'center' | 'right' | null, + |}) => void, + gameEditorMode: 'embedded-game' | 'instances-editor', continueProcessingFunctionCallsOnMount?: boolean, onOpenAskAi: (?OpenAskAiOptions) => void, |}; @@ -157,6 +167,8 @@ export type AskAiEditorInterface = {| aiRequestId: string | null, |} ) => void, + notifyChangesToInGameEditor: (hotReloadSteps: HotReloadSteps) => void, + switchInGameEditorIfNoHotReloadIsNeeded: () => void, |}; const noop = () => {}; @@ -181,6 +193,7 @@ export const AskAiEditor = React.memo( onObjectGroupsModifiedOutsideEditor, onExtensionInstalled, onOpenAskAi, + gameEditorMode, continueProcessingFunctionCallsOnMount, }: Props, ref @@ -834,6 +847,8 @@ export const AskAiEditor = React.memo( onObjectsModifiedOutsideEditor: noop, onObjectGroupsModifiedOutsideEditor: noop, startOrOpenChat: onStartOrOpenChat, + notifyChangesToInGameEditor: setEditorHotReloadNeeded, + switchInGameEditorIfNoHotReloadIsNeeded: noop, })); const onSendFeedback = React.useCallback( @@ -970,6 +985,7 @@ export const renderAskAiEditorContainer = ( } onExtensionInstalled={props.onExtensionInstalled} onOpenAskAi={props.onOpenAskAi} + gameEditorMode={props.gameEditorMode} continueProcessingFunctionCallsOnMount={ props.extraEditorProps ? props.extraEditorProps.continueProcessingFunctionCallsOnMount diff --git a/newIDE/app/src/AssetStore/AssetPackInstallDialog.js b/newIDE/app/src/AssetStore/AssetPackInstallDialog.js index 744667c39b..412153453d 100644 --- a/newIDE/app/src/AssetStore/AssetPackInstallDialog.js +++ b/newIDE/app/src/AssetStore/AssetPackInstallDialog.js @@ -48,7 +48,7 @@ type Props = {| assetShortHeaders: Array, addedAssetIds: Set, onClose: () => void, - onAssetsAdded: (createdObjects: gdObject[]) => void, + onAssetsAdded: InstallAssetOutput => void, onExtensionInstalled: (extensionNames: Array) => void, project: gdProject, objectsContainer: ?gdObjectsContainer, @@ -219,7 +219,6 @@ const AssetPackInstallDialog = ({ if (!installOutput) { throw new Error('Unable to install the asset.'); } - return installOutput; }); @@ -231,13 +230,21 @@ const AssetPackInstallDialog = ({ } await resourceManagementProps.onFetchNewlyAddedResources(); + resourceManagementProps.onNewResourcesAdded(); setAreAssetsBeingInstalled(false); const createdObjects = results .map(result => result.createdObjects) .flat(); + const isTheFirstOfItsTypeInProject = results + .map(result => result.isTheFirstOfItsTypeInProject) + .reduce( + (accumulator: boolean, currentValue: boolean) => + accumulator || currentValue, + false + ); complyVariantsToEventsBasedObjectOf(project, createdObjects); - onAssetsAdded(createdObjects); + onAssetsAdded({ createdObjects, isTheFirstOfItsTypeInProject }); } catch (error) { setAreAssetsBeingInstalled(false); console.error('Error while installing the assets', error); diff --git a/newIDE/app/src/AssetStore/InstallAsset.js b/newIDE/app/src/AssetStore/InstallAsset.js index 2c2c72d1f2..8330f9c558 100644 --- a/newIDE/app/src/AssetStore/InstallAsset.js +++ b/newIDE/app/src/AssetStore/InstallAsset.js @@ -121,6 +121,7 @@ export const installResource = ( export type InstallAssetOutput = {| createdObjects: Array, + isTheFirstOfItsTypeInProject: boolean, |}; export type InstallAssetArgs = {| @@ -254,6 +255,8 @@ export const addAssetToProject = async ({ objectsContainer.hasObjectNamed(name) ); + // Editor hot-reload is already triggered by onFetchNewlyAddedResources at the + // end of asset installation. There is no need to trigger it here too. let object: gdObject; if (targetObjectFolderOrObject) { const { folder, position } = getInsertionParentAndPositionFromSelection( @@ -327,6 +330,8 @@ export const addAssetToProject = async ({ return { createdObjects, + // This boolean is set by `useInstallAsset` + isTheFirstOfItsTypeInProject: false, }; }; diff --git a/newIDE/app/src/AssetStore/NewObjectDialog.js b/newIDE/app/src/AssetStore/NewObjectDialog.js index bffab5a2a1..9800ae35fb 100644 --- a/newIDE/app/src/AssetStore/NewObjectDialog.js +++ b/newIDE/app/src/AssetStore/NewObjectDialog.js @@ -50,6 +50,9 @@ import type { ObjectFolderOrObjectWithContext } from '../ObjectsList/EnumerateOb import LoaderModal from '../UI/LoaderModal'; import { AssetStoreNavigatorContext } from './AssetStoreNavigator'; import InAppTutorialContext from '../InAppTutorial/InAppTutorialContext'; +import uniq from 'lodash/uniq'; + +const gd: libGDevelop = global.gd; const isDev = Window.isDev(); @@ -253,6 +256,13 @@ export const useInstallAsset = ({ project, onExtensionInstalled, }); + + const isTheFirstOfItsTypeInProject = uniq( + asset.objectAssets.map(objectAsset => objectAsset.object.type) + ).some( + objectType => !gd.UsedObjectTypeFinder.scanProject(project, objectType) + ); + const isPrivate = isPrivateAsset(assetShortHeader); const installOutput = isPrivate ? await installPrivateAsset({ @@ -295,6 +305,8 @@ export const useInstallAsset = ({ ); await resourceManagementProps.onFetchNewlyAddedResources(); + resourceManagementProps.onNewResourcesAdded(); + installOutput.isTheFirstOfItsTypeInProject = isTheFirstOfItsTypeInProject; return installOutput; } catch (error) { console.error('Error while installing the asset:', error); @@ -317,7 +329,7 @@ type Props = {| resourceManagementProps: ResourceManagementProps, onClose: () => void, onCreateNewObject: (type: string) => void, - onObjectsAddedFromAssets: (Array) => void, + onObjectsAddedFromAssets: InstallAssetOutput => void, targetObjectFolderOrObjectWithContext?: ?ObjectFolderOrObjectWithContext, onExtensionInstalled: (extensionNames: Array) => void, |}; @@ -403,8 +415,7 @@ function NewObjectDialog({ objectsContainer, }); setIsAssetBeingInstalled(false); - if (installAssetOutput) - onObjectsAddedFromAssets(installAssetOutput.createdObjects); + if (installAssetOutput) onObjectsAddedFromAssets(installAssetOutput); return !!installAssetOutput; }, [installAsset, onObjectsAddedFromAssets, objectsContainer] @@ -676,9 +687,9 @@ function NewObjectDialog({ assetShortHeaders={displayedAssetShortHeaders} addedAssetIds={existingAssetStoreIds} onClose={() => setIsAssetPackDialogInstallOpen(false)} - onAssetsAdded={createdObjects => { + onAssetsAdded={installAssetOutput => { setIsAssetPackDialogInstallOpen(false); - onObjectsAddedFromAssets(createdObjects); + onObjectsAddedFromAssets(installAssetOutput); }} project={project} objectsContainer={objectsContainer} diff --git a/newIDE/app/src/CommandPalette/CommandManager.js b/newIDE/app/src/CommandPalette/CommandManager.js index ea5310c1f1..a5cdcb806c 100644 --- a/newIDE/app/src/CommandPalette/CommandManager.js +++ b/newIDE/app/src/CommandPalette/CommandManager.js @@ -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]; }; diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index c8bd33fa45..93d2448ddb 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -20,6 +20,7 @@ export type CommandName = | 'INVITE_COLLABORATORS' | 'OPEN_RECENT_PROJECT' | 'OPEN_COMMAND_PALETTE' + | 'RESTART_IN_GAME_EDITOR' | 'OPEN_PROJECT_PROPERTIES' | 'OPEN_PROJECT_LOADING_SCREEN' | 'OPEN_PROJECT_VARIABLES' @@ -162,6 +163,10 @@ const commandsList: { [CommandName]: CommandMetadata } = { displayText: t`Open command palette`, ghost: true, }, + RESTART_IN_GAME_EDITOR: { + area: 'IDE', + displayText: t`Restart 3D editor`, + }, // Project manager commands OPEN_PROJECT_PROPERTIES: { diff --git a/newIDE/app/src/CompactPropertiesEditor/index.js b/newIDE/app/src/CompactPropertiesEditor/index.js index 590c7131e3..cc9e3c9d10 100644 --- a/newIDE/app/src/CompactPropertiesEditor/index.js +++ b/newIDE/app/src/CompactPropertiesEditor/index.js @@ -784,7 +784,7 @@ const CompactPropertiesEditor = ({ return ( - + ); }, diff --git a/newIDE/app/src/Debugger/DebuggerSelector.js b/newIDE/app/src/Debugger/DebuggerSelector.js index f139989e92..1d63aae078 100644 --- a/newIDE/app/src/Debugger/DebuggerSelector.js +++ b/newIDE/app/src/Debugger/DebuggerSelector.js @@ -1,38 +1,57 @@ // @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, + debuggerStatus: { [DebuggerId]: DebuggerStatus }, onChooseDebugger: DebuggerId => void, |}; export default class DebuggerSelector extends React.Component { render() { - const hasDebuggers = !!this.props.debuggerIds.length; + const debuggerIds = Object.keys(this.props.debuggerStatus); + const debuggerIdsWithoutInGameEdition = debuggerIds.filter( + id => !this.props.debuggerStatus[id].isInGameEdition + ); + const hasDebuggers = !!debuggerIdsWithoutInGameEdition.length; return ( - - this.props.onChooseDebugger(parseInt(value, 10) || 0) - } - disabled={!hasDebuggers} - > - {this.props.debuggerIds.map(id => ( - - ))} - {!hasDebuggers && ( - + + {({ i18n }) => ( + this.props.onChooseDebugger(value)} + disabled={!hasDebuggers} + > + {debuggerIdsWithoutInGameEdition.map(id => { + const status = this.props.debuggerStatus[id]; + const statusText = status.isPaused ? t`Paused` : t`Playing`; + + return ( + + ); + })} + {!hasDebuggers && ( + + )} + )} - + ); } } diff --git a/newIDE/app/src/Debugger/index.js b/newIDE/app/src/Debugger/index.js index 9d2c66405c..e7040c66c5 100644 --- a/newIDE/app/src/Debugger/index.js +++ b/newIDE/app/src/Debugger/index.js @@ -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 }, |}; @@ -70,27 +71,31 @@ export default class Debugger extends React.Component { debuggerGameData: {}, profilerOutputs: {}, profilingInProgress: {}, - gameIsPaused: {}, - selectedId: 0, + debuggerStatus: {}, + selectedId: '0', logs: {}, }; _debuggerContents: { [DebuggerId]: ?DebuggerContent } = {}; - _debuggerLogs: Map = new Map(); + _debuggerLogs: Map = 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( 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 && @@ -123,7 +128,7 @@ export default class Debugger extends React.Component { } } - _getLogsManager(id: number): LogsManager { + _getLogsManager(id: DebuggerId): LogsManager { let result = this._debuggerLogs.get(id); if (!result) { result = new LogsManager(); @@ -161,14 +166,14 @@ export default class Debugger extends React.Component { 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 { debuggerGameData, profilerOutputs, profilingInProgress, - gameIsPaused, + debuggerStatus, }; }, () => this.updateToolbar() @@ -219,6 +224,11 @@ export default class Debugger extends React.Component { 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 { [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 { 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 === 'updateInstances') { + // 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 { 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 { debuggerServerError, debuggerServerState, selectedId, - debuggerIds, + debuggerStatus, debuggerGameData, profilerOutputs, profilingInProgress, @@ -375,7 +373,7 @@ export default class Debugger extends React.Component { this.setState( { diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 23834a877b..47abd57bce 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -160,6 +160,7 @@ export type InstancesOutsideEditorChanges = {| export type ObjectsOutsideEditorChanges = {| scene: gdLayout, + isNewObjectTypeUsed: boolean, |}; export type ObjectGroupsOutsideEditorChanges = {| @@ -558,6 +559,11 @@ const createOrReplaceObject: EditorFunction = { }; const createNewObject = async () => { + const isTheFirstOfItsTypeInProject = !gd.UsedObjectTypeFinder.scanProject( + project, + object_type + ); + // Check if object already exists. let existingObject: gdObject | null = null; let isGlobalObject = false; @@ -585,6 +591,7 @@ const createOrReplaceObject: EditorFunction = { // This will force the objects panel to refresh. onObjectsModifiedOutsideEditor({ scene: layout, + isNewObjectTypeUsed: false, // No object was actually added. }); return makeGenericSuccess( `Object with name "${object_name}" already exists, no need to re-create it.` @@ -616,6 +623,7 @@ const createOrReplaceObject: EditorFunction = { // This will force the objects panel to refresh. onObjectsModifiedOutsideEditor({ scene: layout, + isNewObjectTypeUsed: isTheFirstOfItsTypeInProject, }); if (createdObjects.length === 1) { @@ -685,7 +693,9 @@ const createOrReplaceObject: EditorFunction = { // This will force the objects panel to refresh. onObjectsModifiedOutsideEditor({ scene: layout, + isNewObjectTypeUsed: isTheFirstOfItsTypeInProject, }); + return makeGenericSuccess( [ `Created a new object (from scratch) called "${object_name}" of type "${object_type}" in scene "${scene_name}".`, @@ -766,6 +776,7 @@ const createOrReplaceObject: EditorFunction = { // This will force the objects panel to refresh. onObjectsModifiedOutsideEditor({ scene: layout, + isNewObjectTypeUsed: false, // The object type was not changed. }); return makeGenericSuccess( `Replaced object "${existingObject.getName()}" by an object from the asset store fitting the search.` @@ -829,6 +840,7 @@ const createOrReplaceObject: EditorFunction = { // This will force the objects panel to refresh. onObjectsModifiedOutsideEditor({ scene: layout, + isNewObjectTypeUsed: false, // The object type can't be new because it is duplicated. }); return makeGenericSuccess( `Duplicated object "${duplicatedObjectName}" as "${newObject.getName()}". The new object "${newObject.getName()}" has the same type, behaviors, properties and effects as the one it was duplicated from.` diff --git a/newIDE/app/src/EffectsList/index.js b/newIDE/app/src/EffectsList/index.js index 10e87af8e4..26b995279d 100644 --- a/newIDE/app/src/EffectsList/index.js +++ b/newIDE/app/src/EffectsList/index.js @@ -397,16 +397,6 @@ const Effect = React.forwardRef( } ); -type Props = {| - project: gdProject, - resourceManagementProps: ResourceManagementProps, - effectsContainer: gdEffectsContainer, - onEffectsUpdated: () => void, - onEffectsRenamed: (oldName: string, newName: string) => void, - target: 'object' | 'layer', - layerRenderingType: string, -|}; - export const getEnumeratedEffectMetadata = ( allEffectDescriptions: Array, effectType: string @@ -477,12 +467,14 @@ export const useManageEffects = ({ effectsContainer, project, onEffectsUpdated, + onEffectAdded, onUpdate, target, }: {| effectsContainer: gdEffectsContainer, project: gdProject, onEffectsUpdated: () => void, + onEffectAdded: () => void, onUpdate: () => void, target: 'object' | 'layer', |}): UseManageEffectsState => { @@ -544,8 +536,11 @@ export const useManageEffects = ({ onUpdate(); onEffectsUpdated(); + // Changing the type is like adding a new effect. + // TODO Make a new effect dialog like for objects or behaviors to make this clearer. + onEffectAdded(); }, - [allEffectMetadata, onUpdate, onEffectsUpdated] + [allEffectMetadata, onUpdate, onEffectsUpdated, onEffectAdded] ); const _addEffect = React.useCallback( @@ -809,6 +804,17 @@ export const useManageEffects = ({ }; }; +type Props = {| + project: gdProject, + resourceManagementProps: ResourceManagementProps, + effectsContainer: gdEffectsContainer, + onEffectsUpdated: () => void, + onEffectsRenamed: (oldName: string, newName: string) => void, + onEffectAdded: () => void, + target: 'object' | 'layer', + layerRenderingType: string, +|}; + /** * Display a list of effects and allow to add/remove/edit them. * @@ -819,6 +825,7 @@ export default function EffectsList(props: Props) { effectsContainer, onEffectsUpdated, onEffectsRenamed, + onEffectAdded, project, target, } = props; @@ -847,6 +854,7 @@ export default function EffectsList(props: Props) { effectsContainer, project, onEffectsUpdated, + onEffectAdded, onUpdate: forceUpdate, target, }); diff --git a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js new file mode 100644 index 0000000000..770b8495ef --- /dev/null +++ b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js @@ -0,0 +1,685 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import { type PreviewDebuggerServer } from '../ExportAndShare/PreviewLauncher.flow'; +import { objectWithContextReactDndType } from '../ObjectsList'; +import { makeDropTarget } from '../UI/DragAndDrop/DropTarget'; +import Text from '../UI/Text'; +import classes from './EmbeddedGameFrame.module.css'; +import { type DropTargetMonitor } from 'react-dnd'; +import { registerOpenedDialogsCountCallback } from '../UI/Dialog'; +import { + getActiveEmbeddedGameFrameHoleRect, + registerActiveEmbeddedGameFrameHoleCountCallback, +} from './EmbeddedGameFrameHole'; +import KeyboardShortcuts from '../UI/KeyboardShortcuts'; +import { useInGameEditorSettings } from './InGameEditorSettings'; + +type AttachToPreviewOptions = {| + previewIndexHtmlLocation: string, +|}; + +export type PreviewInGameEditorTarget = {| + editorId: string, + sceneName: string | null, + externalLayoutName: string | null, + eventsBasedObjectType: string | null, + eventsBasedObjectVariantName: string | null, +|}; + +export type HotReloadSteps = {| + /** + * Set to `true` when the `ProjectData` must be reloaded. + */ + shouldReloadProjectData: boolean, + /** + * Set to `true` when GDJS libraries must be reloaded. + */ + shouldReloadLibraries: boolean, + /** + * Set to `true` when the resources must be reloaded in memory. + */ + shouldReloadResources: boolean, + /** + * Set to `true` when an hard reload is needed. + */ + shouldHardReload: boolean, + + /** + * The reason for the hot reload. Used for debugging purposes. + */ + reasons: Array, +|}; + +const mergeNeededHotReloadSteps = ( + stepsA: HotReloadSteps, + stepsB: HotReloadSteps +): HotReloadSteps => ({ + shouldReloadProjectData: + stepsA.shouldReloadProjectData || stepsB.shouldReloadProjectData, + shouldReloadLibraries: + stepsA.shouldReloadLibraries || stepsB.shouldReloadLibraries, + shouldReloadResources: + stepsA.shouldReloadResources || stepsB.shouldReloadResources, + shouldHardReload: stepsA.shouldHardReload || stepsB.shouldHardReload, + reasons: [...stepsA.reasons, ...stepsB.reasons], +}); + +const isHotReloadNeeded = (hotReloadSteps: HotReloadSteps): boolean => + hotReloadSteps.shouldReloadProjectData || + hotReloadSteps.shouldReloadLibraries || + hotReloadSteps.shouldReloadResources || + hotReloadSteps.shouldHardReload; + +type ChangeViewPositionCommand = + | 'centerViewOnLastSelectedInstance' + | 'zoomToInitialPosition' + | 'zoomToFitContent' + | 'zoomToFitSelection'; + +type SwitchToSceneEditionOptions = {| + ...PreviewInGameEditorTarget, + ...HotReloadSteps, +|}; + +export type EditorCameraState = {| + cameraMode: 'free' | 'orbit', + positionX: number, + positionY: number, + positionZ: number, + rotationAngle: number, + elevationAngle: number, + distance: number, +|}; + +let onSetEmbededGameFramePreviewLocation: + | null + | (AttachToPreviewOptions => void) = null; +let onSwitchToSceneEdition: null | (SwitchToSceneEditionOptions => void) = null; +let onSetEditorHotReloadNeeded: + | null + | ((hotReloadSteps: HotReloadSteps) => void) = null; +let onIsEditorHotReloadNeeded: null | (() => boolean) = null; +let onSwitchInGameEditorIfNoHotReloadIsNeeded: + | null + | (PreviewInGameEditorTarget => void) = null; +let onPreventGameFramePointerEvents: null | ((enabled: boolean) => void) = null; +let onSetCameraState: + | null + | ((editorId: string, cameraState: EditorCameraState) => void) = null; +let onChangeViewPosition: + | null + | ((command: ChangeViewPositionCommand) => void) = null; + +export const setEmbeddedGameFramePreviewLocation = ({ + previewIndexHtmlLocation, +}: AttachToPreviewOptions) => { + if (!onSetEmbededGameFramePreviewLocation) + throw new Error('No EmbeddedGameFrame registered.'); + onSetEmbededGameFramePreviewLocation({ previewIndexHtmlLocation }); +}; + +export const switchToSceneEdition = (options: SwitchToSceneEditionOptions) => { + if (!onSwitchToSceneEdition) + throw new Error('No EmbeddedGameFrame registered.'); + onSwitchToSceneEdition(options); +}; + +export const setEditorHotReloadNeeded = (hotReloadSteps: HotReloadSteps) => { + if (!onSetEditorHotReloadNeeded) + throw new Error('No EmbeddedGameFrame registered.'); + onSetEditorHotReloadNeeded(hotReloadSteps); +}; + +export const isEditorHotReloadNeeded = (): boolean => { + if (!onIsEditorHotReloadNeeded) + throw new Error('No EmbeddedGameFrame registered.'); + return onIsEditorHotReloadNeeded(); +}; + +export const setCameraState = ( + editorId: string, + cameraState: EditorCameraState +) => { + if (!onSetCameraState) throw new Error('No EmbeddedGameFrame registered.'); + onSetCameraState(editorId, cameraState); +}; + +export const switchInGameEditorIfNoHotReloadIsNeeded = ( + previewInGameEditorTarget: PreviewInGameEditorTarget +) => { + if (!onSwitchInGameEditorIfNoHotReloadIsNeeded) + throw new Error('No EmbeddedGameFrame registered.'); + onSwitchInGameEditorIfNoHotReloadIsNeeded(previewInGameEditorTarget); +}; + +export const preventGameFramePointerEvents = (enabled: boolean) => { + if (!onPreventGameFramePointerEvents) + throw new Error('No EmbeddedGameFrame registered.'); + onPreventGameFramePointerEvents(enabled); +}; + +export const changeViewPosition = (command: ChangeViewPositionCommand) => { + if (!onChangeViewPosition) return; + onChangeViewPosition(command); +}; + +const logSwitchingInfo = ({ + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + reasons, +}: {| + ...PreviewInGameEditorTarget, + reasons: Array, +|}) => { + console.info( + eventsBasedObjectType + ? `Switching in-game edition preview for variant "${eventsBasedObjectVariantName || + ''}" of "${eventsBasedObjectType || ''}". Reason(s): ${reasons.join( + ', ' + )}.` + : externalLayoutName + ? `Switching in-game edition previews to external layout "${externalLayoutName || + ''}" (scene: "${sceneName || ''}". Reason(s): ${reasons.join(', ')}).` + : `Switching in-game edition previews to scene "${sceneName || + ''}". Reason(s): ${reasons.join(', ')}.` + ); +}; + +type Props = {| + previewDebuggerServer: PreviewDebuggerServer | null, + enabled: boolean, + onLaunchPreviewForInGameEdition: ({| + ...PreviewInGameEditorTarget, + ...HotReloadSteps, + editorCameraState3D: EditorCameraState | null, + |}) => Promise, +|}; + +const DropTarget = makeDropTarget<{||}>(objectWithContextReactDndType); + +const noHotReloadSteps = { + shouldReloadProjectData: false, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: [], +}; + +export const EmbeddedGameFrame = ({ + previewDebuggerServer, + onLaunchPreviewForInGameEdition, + enabled, +}: Props) => { + const [ + previewIndexHtmlLocation, + setPreviewIndexHtmlLocation, + ] = React.useState(''); + const [ + isPointerEventsPrevented, + setIsPointerEventsPrevented, + ] = React.useState(false); + const iframeRef = React.useRef(null); + const hotReloadSteps = React.useRef(noHotReloadSteps); + const lastPreviewContainer = React.useRef( + null + ); + const isPreviewOngoing = React.useRef(false); + const cameraStates = React.useRef>( + new Map() + ); + const keyboardShortcuts = React.useRef( + new KeyboardShortcuts({ + isActive: () => true, + shortcutCallbacks: {}, + }) + ); + + const inGameEditorSettings = useInGameEditorSettings(); + React.useEffect( + () => { + if (!previewDebuggerServer) return; + + previewDebuggerServer + .getExistingDebuggerIds() + .forEach((debuggerId: string) => { + previewDebuggerServer.sendMessage(debuggerId, { + command: 'setInGameEditorSettings', + payload: { inGameEditorSettings }, + }); + }); + }, + [previewDebuggerServer, inGameEditorSettings] + ); + + React.useEffect( + () => { + // TODO: use a real context for this to handle several in-game editors. + onSetEmbededGameFramePreviewLocation = ( + options: AttachToPreviewOptions + ): void => { + setPreviewIndexHtmlLocation(options.previewIndexHtmlLocation); + const iframe = iframeRef.current; + if (iframe) { + iframe.contentWindow.focus(); + } + }; + onPreventGameFramePointerEvents = (enabled: boolean) => { + setIsPointerEventsPrevented(enabled); + }; + onSetEditorHotReloadNeeded = (addedHotReloadSteps: HotReloadSteps) => { + hotReloadSteps.current = mergeNeededHotReloadSteps( + hotReloadSteps.current, + addedHotReloadSteps + ); + }; + onIsEditorHotReloadNeeded = (): boolean => { + return isHotReloadNeeded(hotReloadSteps.current); + }; + onSetCameraState = (editorId: string, cameraState: EditorCameraState) => { + cameraStates.current.set(editorId, cameraState); + }; + onSwitchToSceneEdition = (options: SwitchToSceneEditionOptions) => { + if (!previewDebuggerServer) return; + if (!enabled) return; + + const { + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + } = options; + + lastPreviewContainer.current = { + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + }; + if (isPreviewOngoing.current) { + const { + shouldReloadProjectData, + shouldReloadLibraries, + shouldReloadResources, + shouldHardReload, + reasons, + } = options; + setEditorHotReloadNeeded({ + shouldReloadProjectData, + shouldReloadLibraries, + shouldReloadResources, + shouldHardReload, + reasons, + }); + return; + } + + const { + shouldReloadProjectData, + shouldReloadLibraries, + shouldReloadResources, + shouldHardReload, + reasons, + } = mergeNeededHotReloadSteps(hotReloadSteps.current, { + shouldReloadProjectData: options.shouldReloadProjectData, + shouldReloadLibraries: options.shouldReloadLibraries, + shouldReloadResources: options.shouldReloadResources, + shouldHardReload: options.shouldHardReload, + reasons: options.reasons, + }); + const hotReload = isHotReloadNeeded({ + shouldReloadProjectData, + shouldReloadLibraries, + shouldReloadResources, + shouldHardReload, + reasons, + }); + if (!previewIndexHtmlLocation || hotReload) { + console.info( + eventsBasedObjectType + ? `Launching in-game edition preview for variant "${eventsBasedObjectVariantName || + ''}" of "${eventsBasedObjectType || + ''}". Reason(s): ${reasons.join(', ')}.` + : externalLayoutName + ? `Launching in-game edition preview for external layout "${externalLayoutName || + ''}" (scene: "${sceneName || ''}"). Reason(s): ${reasons.join( + ', ' + )}.` + : `Launching in-game edition preview for scene "${sceneName || + ''}". Reason(s): ${reasons.join(', ')}.` + ); + hotReloadSteps.current = noHotReloadSteps; + isPreviewOngoing.current = true; + + onLaunchPreviewForInGameEdition({ + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + shouldReloadProjectData, + shouldReloadLibraries, + shouldReloadResources, + shouldHardReload, + reasons, + editorCameraState3D: cameraStates.current.get(editorId) || null, + }).finally(() => { + isPreviewOngoing.current = false; + if ( + isHotReloadNeeded(hotReloadSteps.current) && + lastPreviewContainer.current + ) { + switchToSceneEdition({ + ...lastPreviewContainer.current, + shouldReloadProjectData: false, + shouldReloadLibraries: false, + shouldReloadResources: false, + shouldHardReload: false, + reasons: ['post-launch-preview'], + }); + } + }); + } else { + logSwitchingInfo({ + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + reasons, + }); + previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command: 'switchForInGameEdition', + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + editorCamera3D: cameraStates.current.get(editorId), + }); + }); + } + }; + onSwitchInGameEditorIfNoHotReloadIsNeeded = ({ + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + }: PreviewInGameEditorTarget) => { + if (!previewDebuggerServer) return; + if (!enabled) return; + if (isHotReloadNeeded(hotReloadSteps.current)) { + return; + } + lastPreviewContainer.current = { + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + }; + logSwitchingInfo({ + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + reasons: ['switched-editor-and-no-hot-reload-is-needed'], + }); + previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command: 'switchForInGameEdition', + editorId, + sceneName, + externalLayoutName, + eventsBasedObjectType, + eventsBasedObjectVariantName, + cameraState3D: cameraStates.current.get(editorId), + }); + }); + }; + onChangeViewPosition = (command: ChangeViewPositionCommand) => { + const iframe = iframeRef.current; + if (!iframe) return; + + const embeddedGameFrameRect = iframe.getBoundingClientRect(); + const embeddedGameFrameHoleRect = getActiveEmbeddedGameFrameHoleRect(); + if (!embeddedGameFrameHoleRect || !embeddedGameFrameRect) return; + + if (!previewDebuggerServer) return; + previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command, + payload: { + visibleScreenArea: { + minX: + (embeddedGameFrameHoleRect.left - + embeddedGameFrameRect.left) / + embeddedGameFrameRect.width, + minY: + (embeddedGameFrameHoleRect.top - embeddedGameFrameRect.top) / + embeddedGameFrameRect.height, + maxX: + (embeddedGameFrameHoleRect.right - + embeddedGameFrameRect.left) / + embeddedGameFrameRect.width, + maxY: + (embeddedGameFrameHoleRect.bottom - + embeddedGameFrameRect.top) / + embeddedGameFrameRect.height, + }, + }, + }); + }); + }; + }, + [ + previewDebuggerServer, + previewIndexHtmlLocation, + onLaunchPreviewForInGameEdition, + enabled, + ] + ); + + // Register the iframe window in the debugger as soon as the iframe is shown. + React.useEffect(() => { + const iframe = iframeRef.current; + const hasSomethingLoaded = !!previewIndexHtmlLocation; + if (previewDebuggerServer && iframe && hasSomethingLoaded) + previewDebuggerServer.registerEmbeddedGameFrame(iframe.contentWindow); + }); + + // Unregister the iframe window in the debugger when the EmbeddedGameFrame is unmounted + // (or in the unlikely case the previewDebuggerServer is changed). + React.useEffect( + () => { + const iframe = iframeRef.current; + const previousPreviewDebuggerServer = previewDebuggerServer; + return () => { + if (previousPreviewDebuggerServer && iframe) { + previousPreviewDebuggerServer.unregisterEmbeddedGameFrame( + iframe.contentWindow + ); + } + }; + }, + [previewDebuggerServer] + ); + + const [isDraggedItem3D, setDraggedItem3D] = React.useState(false); + const dropTargetRef = React.useRef(null); + + const dragNewInstance = React.useCallback( + ({ + monitor, + dropped, + isAltPressed, + }: { + monitor: DropTargetMonitor, + dropped: boolean, + isAltPressed: boolean, + }) => { + const dropTarget = dropTargetRef.current; + if (!previewDebuggerServer || !dropTarget) return; + + const name = monitor.getItem().name; + if (!name) return; + + setDraggedItem3D(!!monitor.getItem().is3D); + + const clientOffset = monitor.getClientOffset(); + const dropTargetRect = dropTarget.getBoundingClientRect(); + + previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command: 'dragNewInstance', + x: clientOffset.x - dropTargetRect.left, + y: clientOffset.y - dropTargetRect.top, + name, + dropped, + isAltPressed: keyboardShortcuts.current.shouldNotSnapToGrid(), + }); + }); + }, + [previewDebuggerServer] + ); + + React.useEffect( + () => { + let hasSomeDialogOpen = false; + let hasSomeEmbeddedGameFrameHoleActive = false; + + const sendInGameEditorVisibleStatus = () => { + if (previewDebuggerServer) { + previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { + command: 'setVisibleStatus', + visible: !hasSomeDialogOpen && hasSomeEmbeddedGameFrameHoleActive, + }); + }); + } + }; + + const unregisterDialogOpenCallback = registerOpenedDialogsCountCallback( + ({ openedDialogsCount }) => { + hasSomeDialogOpen = openedDialogsCount > 0; + sendInGameEditorVisibleStatus(); + } + ); + + const unregisterEmbeddedGameFrameHoleActiveCallback = registerActiveEmbeddedGameFrameHoleCountCallback( + ({ activeEmbeddedGameFrameHoleCount }) => { + hasSomeEmbeddedGameFrameHoleActive = + activeEmbeddedGameFrameHoleCount > 0; + sendInGameEditorVisibleStatus(); + } + ); + + return () => { + unregisterDialogOpenCallback(); + unregisterEmbeddedGameFrameHoleActiveCallback(); + }; + }, + [previewDebuggerServer] + ); + + return ( +
+
+