From 8e50ddefe4f0c7cc5013c982117531eff1ae2a4c Mon Sep 17 00:00:00 2001 From: D8H Date: Mon, 4 May 2026 10:45:51 +0200 Subject: [PATCH] Allow to configure object resources to be loaded independently from the scene (#8514) - This is an advanced, experimental feature which is subject to changes. It's in most cases not useful, unless you have a large game or low end devices for which controlling memory usage is important. It's mostly useful to delay loading of large custom objects. - Objects can be configured so that its resources are not loaded when the scene is loaded. Instead, you can use an action to manually load the resources used by it. When the loading is done (this can be checked using a condition), the object can then be created. Be careful: if an object assets are not loaded, it will be broken when displayed on screen. --- .../Extensions/Builtin/SceneExtension.cpp | 42 + .../IDE/Project/ArbitraryObjectsWorker.h | 9 +- .../IDE/Project/ArbitraryResourceWorker.cpp | 14 +- .../IDE/Project/ArbitraryResourceWorker.h | 16 +- .../IDE/Project/SceneResourcesFinder.cpp | 25 +- .../GDCore/IDE/Project/SceneResourcesFinder.h | 11 +- Core/GDCore/IDE/ProjectStripper.cpp | 1 - Core/GDCore/IDE/ResourceExposer.cpp | 16 +- Core/GDCore/IDE/ResourceExposer.h | 16 +- Core/GDCore/Project/Object.cpp | 5 + Core/GDCore/Project/Object.h | 18 + Core/GDCore/Project/Project.h | 8 +- .../tests/anchorruntimebehavior.spec.js | 1 + .../tests/draggableruntimebehavior.spec.js | 1 + .../LinkedObjects/tests/linkedobjects.spec.js | 1 + ...rameRatePathfindingRuntimeBehavior.spec.js | 1 + .../commonpathfindingruntimebehavior.spec.js | 1 + .../legacypathfindingruntimebehavior.spec.js | 1 + ...meobject.pixiruntimegamewithassets.spec.js | 1 + Extensions/SaveState/tests/SaveState.spec.js | 1 + ...meobject.pixiruntimegamewithassets.spec.js | 1 + .../tilemapcollisionmaskruntimeobject.spec.js | 1 + .../tests/topdownmovementbehavior.spec.js | 1 + .../Extensions/Builtin/SceneExtension.cpp | 6 + GDJS/GDJS/IDE/ExporterHelper.cpp | 72 +- GDJS/GDJS/IDE/ExporterHelper.h | 19 +- GDJS/Runtime/ResourceLoader.ts | 1050 +++++++++++------ .../Runtime/events-tools/runtimescenetools.ts | 32 + GDJS/Runtime/runtimegame.ts | 110 ++ GDJS/Runtime/types/project-data.d.ts | 12 + .../init.pixiruntimegamewithassets.js | 1 + GDJS/tests/tests/ResourceLoader.js | 243 +++- GDJS/tests/tests/effects.js | 1 + GDJS/tests/tests/hot-reloader.js | 2 + GDJS/tests/tests/runtimescene.js | 1 + GDJS/tests/tests/scenestack.js | 1 + GDevelop.js/Bindings/Bindings.idl | 2 + GDevelop.js/types.d.ts | 2 + GDevelop.js/types/gdobject.js | 2 + .../CompactPropertiesEditorByVisibility.js | 4 +- .../CompactObjectPropertiesEditor/index.js | 47 +- 41 files changed, 1396 insertions(+), 403 deletions(-) diff --git a/Core/GDCore/Extensions/Builtin/SceneExtension.cpp b/Core/GDCore/Extensions/Builtin/SceneExtension.cpp index 411b4d7d1..58247fe9d 100644 --- a/Core/GDCore/Extensions/Builtin/SceneExtension.cpp +++ b/Core/GDCore/Extensions/Builtin/SceneExtension.cpp @@ -216,6 +216,48 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSceneExtension( .AddCodeOnlyParameter("currentScene", "") .AddParameter("sceneName", _("Scene name")) .MarkAsAdvanced(); + + extension + .AddAction( + "LoadObjectAssets", + _("Preload object"), + _("Preload an object resources in background."), + _("Preload object _PARAM1_ in background"), + "", + "res/actions/hourglass_black.svg", + "res/actions/hourglass_black.svg") + .SetHelpPath("/all-features/resources-loading") + .AddCodeOnlyParameter("currentScene", "") + .AddParameter("string", _("Object name")) + .MarkAsAdvanced(); + + extension + .AddAction( + "UnloadObjectAssets", + _("Unload object"), + _("Unload an object resources. The \"resource preloading\" property must be set to \"preload with an action\" for this action to actually unload resources."), + _("Unload object _PARAM1_"), + "", + "res/actions/hourglass_black.svg", + "res/actions/hourglass_black.svg") + .SetHelpPath("/all-features/resources-loading") + .AddCodeOnlyParameter("currentScene", "") + .AddParameter("string", _("Object name")) + .MarkAsAdvanced(); + + extension + .AddCondition( + "AreObjectAssetsLoaded", + _("Object preloaded"), + _("Check if object resources have finished to load in background."), + _("Object _PARAM1_ was preloaded in background"), + "", + "res/actions/hourglass_black.svg", + "res/actions/hourglass_black.svg") + .SetHelpPath("/all-features/resources-loading") + .AddCodeOnlyParameter("currentScene", "") + .AddParameter("string", _("Object name")) + .MarkAsAdvanced(); } } // namespace gd diff --git a/Core/GDCore/IDE/Project/ArbitraryObjectsWorker.h b/Core/GDCore/IDE/Project/ArbitraryObjectsWorker.h index c289016e6..c6fc8bfd0 100644 --- a/Core/GDCore/IDE/Project/ArbitraryObjectsWorker.h +++ b/Core/GDCore/IDE/Project/ArbitraryObjectsWorker.h @@ -4,8 +4,7 @@ * reserved. This project is released under the MIT License. */ -#ifndef ARBITRARYOBJECTSWORKER_H -#define ARBITRARYOBJECTSWORKER_H +#pragma once #include #include @@ -37,6 +36,11 @@ class GD_CORE_API ArbitraryObjectsWorker { */ void Launch(gd::ObjectsContainer& objects) { VisitObjectContainer(objects); }; + /** + * \brief Launch the worker on the specified object. + */ + void Launch(gd::Object& object) { VisitObject(object); }; + private: void VisitObjectContainer(gd::ObjectsContainer& objects); void VisitObject(gd::Object& object); @@ -60,4 +64,3 @@ class GD_CORE_API ArbitraryObjectsWorker { } // namespace gd -#endif // ARBITRARYOBJECTSWORKER_H diff --git a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp index 0b7e8490b..a7acf9c70 100644 --- a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp +++ b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.cpp @@ -291,6 +291,9 @@ GetResourceWorkerOnEvents(const gd::Project &project, } void ResourceWorkerInObjectsWorker::DoVisitObject(gd::Object &object) { + if (!shouldCheckObject(object)) { + return; + } object.GetConfiguration().ExposeResources(worker); auto& effects = object.GetEffects(); for (size_t effectIndex = 0; effectIndex < effects.GetEffectsCount(); effectIndex++) @@ -307,7 +310,16 @@ void ResourceWorkerInObjectsWorker::DoVisitBehavior(gd::Behavior &behavior){ gd::ResourceWorkerInObjectsWorker GetResourceWorkerOnObjects(const gd::Project &project, gd::ArbitraryResourceWorker &worker) { - gd::ResourceWorkerInObjectsWorker resourcesWorker(project, worker); + std::function shouldCheckObject = + [](const gd::Object &object) { return true; }; + return GetResourceWorkerOnObjects(project, worker, shouldCheckObject); +} + +gd::ResourceWorkerInObjectsWorker GetResourceWorkerOnObjects( + const gd::Project &project, gd::ArbitraryResourceWorker &worker, + std::function &shouldCheckObject) { + gd::ResourceWorkerInObjectsWorker resourcesWorker(project, worker, + shouldCheckObject); return resourcesWorker; } diff --git a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h index e037fc887..041d4f8f9 100644 --- a/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h +++ b/Core/GDCore/IDE/Project/ArbitraryResourceWorker.h @@ -178,8 +178,11 @@ ResourceWorkerInEventsWorker GD_CORE_API GetResourceWorkerOnEvents( class GD_CORE_API ResourceWorkerInObjectsWorker : public gd::ArbitraryObjectsWorker { public: - ResourceWorkerInObjectsWorker(const gd::Project &project_, gd::ArbitraryResourceWorker &worker_) - : project(project_), worker(worker_){}; + ResourceWorkerInObjectsWorker( + const gd::Project &project_, gd::ArbitraryResourceWorker &worker_, + std::function &shouldCheckObject_) + : project(project_), worker(worker_), + shouldCheckObject(shouldCheckObject_){}; ~ResourceWorkerInObjectsWorker() {} private: @@ -188,9 +191,14 @@ private: const gd::Project &project; gd::ArbitraryResourceWorker &worker; + std::function shouldCheckObject; }; -gd::ResourceWorkerInObjectsWorker GD_CORE_API -GetResourceWorkerOnObjects(const gd::Project &project, gd::ArbitraryResourceWorker &worker); +gd::ResourceWorkerInObjectsWorker GD_CORE_API GetResourceWorkerOnObjects( + const gd::Project &project, gd::ArbitraryResourceWorker &worker); + +gd::ResourceWorkerInObjectsWorker GD_CORE_API GetResourceWorkerOnObjects( + const gd::Project &project, gd::ArbitraryResourceWorker &worker, + std::function &shouldCheckObject); } // namespace gd diff --git a/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp b/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp index fc6b14deb..9d8f8cf94 100644 --- a/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp +++ b/Core/GDCore/IDE/Project/SceneResourcesFinder.cpp @@ -9,6 +9,7 @@ #include "GDCore/Project/EventsBasedObjectVariant.h" #include "GDCore/Project/Layout.h" #include "GDCore/Project/Project.h" +#include "GDCore/Project/Object.h" #include "GDCore/Serialization/SerializerElement.h" namespace gd { @@ -20,11 +21,29 @@ std::set SceneResourcesFinder::FindProjectResources(gd::Project &pro return resourceWorker.resourceNames; } -std::set SceneResourcesFinder::FindSceneResources(gd::Project &project, - gd::Layout &layout) { +std::set +SceneResourcesFinder::FindSceneResources(gd::Project &project, + gd::Layout &layout, + bool ignoreObjectResourcePreloading) { gd::SceneResourcesFinder resourceWorker(project.GetResourcesManager()); - gd::ResourceExposer::ExposeLayoutResources(project, layout, resourceWorker); + std::function shouldCheckObject = + [ignoreObjectResourcePreloading](const gd::Object &object) { + return ignoreObjectResourcePreloading || + object.GetResourcesPreloading() != "manually"; + }; + gd::ResourceExposer::ExposeLayoutResources(project, layout, resourceWorker, + shouldCheckObject); + return resourceWorker.resourceNames; +} + +std::set +SceneResourcesFinder::FindObjectResources(gd::Project &project, + gd::Object &object) { + gd::SceneResourcesFinder resourceWorker(project.GetResourcesManager()); + + auto objectWorker = gd::GetResourceWorkerOnObjects(project, resourceWorker); + objectWorker.Launch(object); return resourceWorker.resourceNames; } diff --git a/Core/GDCore/IDE/Project/SceneResourcesFinder.h b/Core/GDCore/IDE/Project/SceneResourcesFinder.h index ea3db9512..e82091b23 100644 --- a/Core/GDCore/IDE/Project/SceneResourcesFinder.h +++ b/Core/GDCore/IDE/Project/SceneResourcesFinder.h @@ -32,8 +32,9 @@ public: * * It doesn't include resources used globally. */ - static std::set FindSceneResources(gd::Project &project, - gd::Layout &layout); + static std::set + FindSceneResources(gd::Project &project, gd::Layout &layout, + bool ignoreObjectResourcePreloading); /** * @brief Find resource that are used globally in the project. @@ -42,6 +43,12 @@ public: */ static std::set FindProjectResources(gd::Project &project); + /** + * @brief Find resource usages in a given object. + */ + static std::set FindObjectResources(gd::Project &project, + gd::Object &object); + /** * @brief Find resource usages in a given events-based object variant. */ diff --git a/Core/GDCore/IDE/ProjectStripper.cpp b/Core/GDCore/IDE/ProjectStripper.cpp index 82d094da3..d13cd8326 100644 --- a/Core/GDCore/IDE/ProjectStripper.cpp +++ b/Core/GDCore/IDE/ProjectStripper.cpp @@ -26,7 +26,6 @@ void GD_CORE_API ProjectStripper::StripProjectForExport(gd::Project &project) { wholeProjectBrowser.ExposeObjects(project, behaviorDefaultFlagClearer); for (unsigned int i = 0; i < project.GetLayoutsCount(); ++i) { - project.GetLayout(i).GetObjects().GetObjectGroups().Clear(); project.GetLayout(i).GetEvents().Clear(); } diff --git a/Core/GDCore/IDE/ResourceExposer.cpp b/Core/GDCore/IDE/ResourceExposer.cpp index 4c47c1f49..a037bb205 100644 --- a/Core/GDCore/IDE/ResourceExposer.cpp +++ b/Core/GDCore/IDE/ResourceExposer.cpp @@ -15,6 +15,7 @@ #include "GDCore/Project/EventsFunctionsExtension.h" #include "GDCore/Project/Layout.h" #include "GDCore/Project/Project.h" +#include "GDCore/Project/Object.h" #include "GDCore/String.h" namespace gd { @@ -89,11 +90,20 @@ void ResourceExposer::ExposeProjectResources( } void ResourceExposer::ExposeLayoutResources( - gd::Project &project, - gd::Layout &layout, + gd::Project &project, gd::Layout &layout, gd::ArbitraryResourceWorker &worker) { + std::function shouldCheckObject = + [](const gd::Object &object) { return true; }; + gd::ResourceExposer::ExposeLayoutResources(project, layout, worker, + shouldCheckObject); +} + +void ResourceExposer::ExposeLayoutResources( + gd::Project &project, gd::Layout &layout, + gd::ArbitraryResourceWorker &worker, + std::function &shouldCheckObject) { // Expose object configuration resources - auto objectWorker = gd::GetResourceWorkerOnObjects(project, worker); + auto objectWorker = gd::GetResourceWorkerOnObjects(project, worker, shouldCheckObject); gd::ProjectBrowserHelper::ExposeLayoutObjects(layout, objectWorker); // Expose layer effect resources diff --git a/Core/GDCore/IDE/ResourceExposer.h b/Core/GDCore/IDE/ResourceExposer.h index 14b65482f..01407f3a1 100644 --- a/Core/GDCore/IDE/ResourceExposer.h +++ b/Core/GDCore/IDE/ResourceExposer.h @@ -5,6 +5,8 @@ */ #pragma once +#include + namespace gd { class Platform; class Project; @@ -13,6 +15,7 @@ class EventsBasedObjectVariant; class EventsFunctionsExtension; class Effect; class Layout; +class Object; } // namespace gd namespace gd { @@ -47,10 +50,19 @@ public: * * It doesn't include resources used globally. */ - static void ExposeLayoutResources(gd::Project &project, - gd::Layout &layout, + static void ExposeLayoutResources(gd::Project &project, gd::Layout &layout, gd::ArbitraryResourceWorker &worker); + /** + * @brief Expose the resources used in a given layout. + * + * It doesn't include resources used globally. + */ + static void ExposeLayoutResources( + gd::Project &project, gd::Layout &layout, + gd::ArbitraryResourceWorker &worker, + std::function &shouldCheckObject); + /** * @brief Expose the resources used in a given events-based object variant. */ diff --git a/Core/GDCore/Project/Object.cpp b/Core/GDCore/Project/Object.cpp index c7b55366c..60420e926 100644 --- a/Core/GDCore/Project/Object.cpp +++ b/Core/GDCore/Project/Object.cpp @@ -52,6 +52,7 @@ void Object::CopyWithoutConfiguration(const gd::Object& object) { objectVariables = object.objectVariables; effectsContainer = object.effectsContainer; behaviors = object.behaviors; + resourcesPreloading = object.resourcesPreloading; } gd::ObjectConfiguration& Object::GetConfiguration() { return *configuration; } @@ -95,6 +96,7 @@ void Object::UnserializeFrom(gd::Project& project, SetType(element.GetStringAttribute("type")); assetStoreId = element.GetStringAttribute("assetStoreId"); name = element.GetStringAttribute("name", name, "nom"); + resourcesPreloading = element.GetStringAttribute("resourcesPreloading", "with-scene"); objectVariables.UnserializeFrom( element.GetChild("variables", 0, "Variables")); @@ -134,6 +136,9 @@ void Object::SerializeTo(SerializerElement& element) const { element.SetAttribute("name", GetName()); element.SetAttribute("assetStoreId", GetAssetStoreId()); element.SetAttribute("type", GetType()); + if (GetResourcesPreloading() != "with-scene") { + element.SetAttribute("resourcesPreloading", GetResourcesPreloading()); + } objectVariables.SerializeTo(element.AddChild("variables")); effectsContainer.SerializeTo(element.AddChild("effects")); behaviors.SerializeTo(element.AddChild("behaviors")); diff --git a/Core/GDCore/Project/Object.h b/Core/GDCore/Project/Object.h index 95c2c69b0..e66fd7b41 100644 --- a/Core/GDCore/Project/Object.h +++ b/Core/GDCore/Project/Object.h @@ -124,6 +124,22 @@ class GD_CORE_API Object { */ const gd::String& GetType() const { return configuration->GetType(); } + /** + * Set when the object resources must be preloaded: `with-scene`(default), + * `manually`. + */ + void SetResourcesPreloading(gd::String resourcesPreloading_) { + resourcesPreloading = resourcesPreloading_; + } + + /** + * Get when the object resources must be preloaded: `with-scene`(default), + * `manually`. + */ + const gd::String& GetResourcesPreloading() const { + return resourcesPreloading; + } + ///@} /** \name Behaviors management @@ -267,6 +283,8 @@ class GD_CORE_API Object { effectsContainer; ///< The effects container for the object. mutable gd::String persistentUuid; ///< A persistent random version 4 UUID, ///< useful for computing changesets. + /** When set to `"manually"`, its resources are not preloaded with the scene. */ + gd::String resourcesPreloading = "with-scene"; /** * Initialize object using another object. Used by copy-ctor and assign-op. diff --git a/Core/GDCore/Project/Project.h b/Core/GDCore/Project/Project.h index 8d8a6b576..95ae2685e 100644 --- a/Core/GDCore/Project/Project.h +++ b/Core/GDCore/Project/Project.h @@ -996,16 +996,16 @@ class GD_CORE_API Project { ResourcesContainer& GetResourcesManager() { return resourcesContainer; } /** - * Set when the scenes must preload their resources: `at-startup`, `never` - * (default). + * Set when the scenes must preload their resources: `at-startup` (default), + * `never`. */ void SetSceneResourcesPreloading(gd::String sceneResourcesPreloading_) { sceneResourcesPreloading = sceneResourcesPreloading_; } /** - * Get when the scenes must preload their resources: `at-startup`, `never` - * (default). + * Get when the scenes must preload their resources: `at-startup` (default), + * `never`. */ const gd::String& GetSceneResourcesPreloading() const { return sceneResourcesPreloading; diff --git a/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js b/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js index 9955314fb..0ec528d1d 100644 --- a/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js +++ b/Extensions/AnchorBehavior/tests/anchorruntimebehavior.spec.js @@ -38,6 +38,7 @@ describe('gdjs.AnchorRuntimeBehavior', () => { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js b/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js index 5d030d6c5..a16257ab5 100644 --- a/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js +++ b/Extensions/DraggableBehavior/tests/draggableruntimebehavior.spec.js @@ -27,6 +27,7 @@ describe('gdjs.DraggableRuntimeBehavior', function () { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/Extensions/LinkedObjects/tests/linkedobjects.spec.js b/Extensions/LinkedObjects/tests/linkedobjects.spec.js index 9a07fa7c4..35582e7be 100644 --- a/Extensions/LinkedObjects/tests/linkedobjects.spec.js +++ b/Extensions/LinkedObjects/tests/linkedobjects.spec.js @@ -26,6 +26,7 @@ describe('gdjs.LinksManager', function () { variables: [], behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], r: 0, v: 0, diff --git a/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js b/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js index c96a140fd..9ad3251cf 100644 --- a/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js +++ b/Extensions/PathfindingBehavior/tests/FrameRatePathfindingRuntimeBehavior.spec.js @@ -34,6 +34,7 @@ describe('gdjs.PathfindingRuntimeBehavior', function () { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js b/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js index 3ca6ff37c..626d1f9b0 100644 --- a/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js +++ b/Extensions/PathfindingBehavior/tests/commonpathfindingruntimebehavior.spec.js @@ -37,6 +37,7 @@ describe('gdjs.PathfindingRuntimeBehavior', function () { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js b/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js index ba1aabdf1..fe5396c9c 100644 --- a/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js +++ b/Extensions/PathfindingBehavior/tests/legacypathfindingruntimebehavior.spec.js @@ -39,6 +39,7 @@ describe('gdjs.PathfindingRuntimeBehavior', function () { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/Extensions/PrimitiveDrawing/tests/shapepainterruntimeobject.pixiruntimegamewithassets.spec.js b/Extensions/PrimitiveDrawing/tests/shapepainterruntimeobject.pixiruntimegamewithassets.spec.js index 1c6197848..f59697309 100644 --- a/Extensions/PrimitiveDrawing/tests/shapepainterruntimeobject.pixiruntimegamewithassets.spec.js +++ b/Extensions/PrimitiveDrawing/tests/shapepainterruntimeobject.pixiruntimegamewithassets.spec.js @@ -47,6 +47,7 @@ describe('gdjs.ShapePainterRuntimeObject (using a PixiJS RuntimeGame with assets title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], variables: [], usedResources: [], diff --git a/Extensions/SaveState/tests/SaveState.spec.js b/Extensions/SaveState/tests/SaveState.spec.js index b55025d2f..55e4fd012 100644 --- a/Extensions/SaveState/tests/SaveState.spec.js +++ b/Extensions/SaveState/tests/SaveState.spec.js @@ -68,6 +68,7 @@ describe('SaveState', () => { updateIfNotVisible: false, }, ], + objectsGroups: [], instances: instances || [], variables: [], usedResources: [], diff --git a/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js b/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js index 49a39f477..99299e2e0 100644 --- a/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js +++ b/Extensions/TextInput/tests/textinputruntimeobject.pixiruntimegamewithassets.spec.js @@ -58,6 +58,7 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], variables: [], usedResources: [], diff --git a/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js b/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js index 3aaa06c10..f3dba783a 100644 --- a/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js +++ b/Extensions/TileMap/tests/tilemapcollisionmaskruntimeobject.spec.js @@ -55,6 +55,7 @@ describe('gdjs.TileMapCollisionMaskRuntimeObject', function () { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js b/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js index abaab93dd..c627f8a9f 100644 --- a/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js +++ b/Extensions/TopDownMovementBehavior/tests/topdownmovementbehavior.spec.js @@ -32,6 +32,7 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/GDJS/GDJS/Extensions/Builtin/SceneExtension.cpp b/GDJS/GDJS/Extensions/Builtin/SceneExtension.cpp index a1619e3c7..7c8093407 100644 --- a/GDJS/GDJS/Extensions/Builtin/SceneExtension.cpp +++ b/GDJS/GDJS/Extensions/Builtin/SceneExtension.cpp @@ -51,6 +51,12 @@ SceneExtension::SceneExtension() { "gdjs.evtTools.runtimeScene.getSceneLoadingProgress"); GetAllExpressions()["SceneLoadingProgress"].SetFunctionName( "gdjs.evtTools.runtimeScene.getSceneLoadingProgress"); + GetAllActions()["LoadObjectAssets"].SetFunctionName( + "gdjs.evtTools.runtimeScene.loadObjectOrGroupAssets"); + GetAllActions()["UnloadObjectAssets"].SetFunctionName( + "gdjs.evtTools.runtimeScene.unloadObjectOrGroupAssets"); + GetAllConditions()["AreObjectAssetsLoaded"].SetFunctionName( + "gdjs.evtTools.runtimeScene.areObjectOrGroupAssetsLoaded"); StripUnimplementedInstructionsAndExpressions(); } diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index cd918eeaa..493d222eb 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -578,7 +578,8 @@ void ExporterHelper::StripAndSerializeProjectData( layoutIndex++) { auto &layout = project.GetLayout(layoutIndex); auto sceneUsedResources = gd::SceneResourcesFinder::FindSceneResources( - project, layout); + project, layout, + /* ignoreObjectResourcePreloading= */ isInGameEdition); for (auto &&resourceName : projectUsedResources) { sceneUsedResources.erase(resourceName); } @@ -619,9 +620,11 @@ void ExporterHelper::StripAndSerializeProjectData( gd::ProjectStripper::StripProjectForExport(project); project.SerializeTo(rootElement); - SerializeUsedResources(rootElement, projectUsedResources, scenesUsedResources, - eventsBasedObjectVariantsUsedResources); + SerializeUsedResourcesForRuntime(project, rootElement, projectUsedResources, + scenesUsedResources); if (isInGameEdition) { + SerializeUsedResourcesForInGameEditor( + project, rootElement, eventsBasedObjectVariantsUsedResources); auto &behaviorsElement = rootElement.AddChild("activatedByDefaultInEditorBehaviors"); behaviorsElement.ConsiderAsArrayOf("resourceReference"); auto &platform = project.GetCurrentPlatform(); @@ -638,23 +641,22 @@ void ExporterHelper::StripAndSerializeProjectData( } void ExporterHelper::SerializeUsedResources( + gd::SerializerElement &element, std::set &usedResources) { + auto &resourcesElement = element.AddChild("usedResources"); + resourcesElement.ConsiderAsArrayOf("resourceReference"); + for (auto &resourceName : usedResources) { + auto &resourceElement = resourcesElement.AddChild("resourceReference"); + resourceElement.SetAttribute("name", resourceName); + } +} + +void ExporterHelper::SerializeUsedResourcesForRuntime( + gd::Project &project, gd::SerializerElement &rootElement, std::set &projectUsedResources, - std::unordered_map> &scenesUsedResources, - std::unordered_map> - &eventsBasedObjectVariantsUsedResources) { - auto serializeUsedResources = - [](gd::SerializerElement &element, - std::set &usedResources) -> void { - auto &resourcesElement = element.AddChild("usedResources"); - resourcesElement.ConsiderAsArrayOf("resourceReference"); - for (auto &resourceName : usedResources) { - auto &resourceElement = resourcesElement.AddChild("resourceReference"); - resourceElement.SetAttribute("name", resourceName); - } - }; + std::unordered_map> &scenesUsedResources) { - serializeUsedResources(rootElement, projectUsedResources); + SerializeUsedResources(rootElement, projectUsedResources); auto &layoutsElement = rootElement.GetChild("layouts"); for (std::size_t layoutIndex = 0; @@ -663,10 +665,36 @@ void ExporterHelper::SerializeUsedResources( auto &layoutElement = layoutsElement.GetChild(layoutIndex); const auto layoutName = layoutElement.GetStringAttribute("name"); - auto &layoutUsedResources = scenesUsedResources[layoutName]; - serializeUsedResources(layoutElement, layoutUsedResources); - } + auto &sceneUsedResources = scenesUsedResources[layoutName]; + SerializeUsedResources(layoutElement, sceneUsedResources); + auto &scene = project.GetLayout(layoutName); + auto &objectsElement = layoutElement.GetChild("objects"); + for (std::size_t objectIndex = 0; + objectIndex < objectsElement.GetChildrenCount(); objectIndex++) { + auto &objectElement = objectsElement.GetChild(objectIndex); + const auto objectName = objectElement.GetStringAttribute("name"); + + auto &object = scene.GetObjects().GetObject(objectName); + if (object.GetResourcesPreloading() == "manually") { + auto objectUsedResources = + gd::SceneResourcesFinder::FindObjectResources(project, object); + for (auto &&resourceName : projectUsedResources) { + objectUsedResources.erase(resourceName); + } + for (auto &&resourceName : sceneUsedResources) { + objectUsedResources.erase(resourceName); + } + SerializeUsedResources(objectElement, objectUsedResources); + } + } + } +} + +void ExporterHelper::SerializeUsedResourcesForInGameEditor( + gd::Project &project, gd::SerializerElement &rootElement, + std::unordered_map> + &eventsBasedObjectVariantsUsedResources) { auto &extensionsElement = rootElement.GetChild("eventsFunctionsExtensions"); for (std::size_t extensionIndex = 0; extensionIndex < extensionsElement.GetChildrenCount(); @@ -685,7 +713,7 @@ void ExporterHelper::SerializeUsedResources( gd::PlatformExtension::GetObjectFullType(extensionName, objectName); auto &objectUsedResources = eventsBasedObjectVariantsUsedResources[eventsBasedObjectType]; - serializeUsedResources(objectElement, objectUsedResources); + SerializeUsedResources(objectElement, objectUsedResources); auto &variantsElement = objectElement.GetChild("variants"); for (std::size_t variantIndex = 0; @@ -697,7 +725,7 @@ void ExporterHelper::SerializeUsedResources( extensionName, objectName, variantName); auto &variantUsedResources = eventsBasedObjectVariantsUsedResources[variantType]; - serializeUsedResources(variantElement, variantUsedResources); + SerializeUsedResources(variantElement, variantUsedResources); } } } diff --git a/GDJS/GDJS/IDE/ExporterHelper.h b/GDJS/GDJS/IDE/ExporterHelper.h index 709422b1c..17bbb97e0 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.h +++ b/GDJS/GDJS/IDE/ExporterHelper.h @@ -751,14 +751,19 @@ class ExporterHelper { ///< be then copied to the final output directory. private: - static void - SerializeUsedResources(gd::SerializerElement &rootElement, - std::set &projectUsedResources, - std::unordered_map> - &layersUsedResources, - std::unordered_map> - &eventsBasedObjectVariantsUsedResources); + static void SerializeUsedResourcesForRuntime( + gd::Project &project, gd::SerializerElement &rootElement, + std::set &projectUsedResources, + std::unordered_map> + &layersUsedResources); + static void SerializeUsedResourcesForInGameEditor( + gd::Project &project, gd::SerializerElement &rootElement, + std::unordered_map> + &eventsBasedObjectVariantsUsedResources); + + static void SerializeUsedResources(gd::SerializerElement &element, + std::set &usedResources); /** * \brief Strip a project and serialize it to JSON. */ diff --git a/GDJS/Runtime/ResourceLoader.ts b/GDJS/Runtime/ResourceLoader.ts index d7513afcd..66d8ed777 100644 --- a/GDJS/Runtime/ResourceLoader.ts +++ b/GDJS/Runtime/ResourceLoader.ts @@ -5,7 +5,8 @@ */ namespace gdjs { const logger = new gdjs.Logger('ResourceLoader'); - const debugLogger = new gdjs.Logger('ResourceLoader - debug').enable(false); + // TODO add a condition before each log to avoid building the message for nothing. + const debugLogger = new gdjs.Logger('ResourceLoader - debug').enable(true); const addSearchParameterToUrl = ( url: string, @@ -36,14 +37,14 @@ namespace gdjs { * and make impossible to finely tune in which order scenes are actually * downloaded. */ - class SceneLoadingTask { - sceneName: string; + class LoadingTask { + identifier: string; private onProgressCallbacks: Array<(count: number, total: number) => void>; private onFinishCallbacks: Array<() => void>; private isFinished = false; - constructor(sceneName: string) { - this.sceneName = sceneName; + constructor(identifier: string) { + this.identifier = identifier; this.onProgressCallbacks = new Array< (count: number, total: number) => void >(); @@ -107,6 +108,16 @@ namespace gdjs { errors: Array>; }; + type LoadingTaskState = { + resourceNames: Array; + /** + * - `'not-loaded'` Resources are not loaded. + * - `'loaded'` Resources are loaded but not parsed. + * - `'ready'` Resources are loaded and parsed. + */ + status: 'not-loaded' | 'loaded' | 'ready'; + }; + /** * Pre-load resources of any kind needed for a game or a scene. * @category Resources @@ -117,30 +128,11 @@ namespace gdjs { static maxAttempt = 3; _runtimeGame: RuntimeGame; - /** - * All the resource of a game by resource name. - */ - private _resources: Map; /** * Resources needed for any scene. Typically, they are resources from * global objects. */ private _globalResources: Array; - /** - * Resources and the loading state of each scene, indexed by scene name. - */ - private _sceneLoadingStates: Map< - string, - { - resourceNames: Array; - status: 'not-loaded' | 'loaded' | 'ready'; - } - > = new Map(); - /** - * A queue of scenes whose resources are still to be pre-loaded. - */ - private _sceneToLoadQueue: Array = - new Array(); /** * The resource managers that actually download and remember downloaded * content. @@ -156,21 +148,28 @@ namespace gdjs { private _spineManager: SpineManager | null = null; private _svgManager: InternalInGameEditorOnlySvgManager; - /** - * The name of the scene for which resources are currently being loaded. - */ - private currentLoadingSceneName: string = ''; - /** - * The progress, between 0 and 1, of the loading of the resource, for the - * scene that is being loaded (see `currentLoadingSceneName`). - */ - private currentSceneLoadingProgress: float = 0; - /** - * It's set to `true` during intermediary loading screen to use a greater - * concurrency as the game is paused and doesn't need bandwidth (for video - * or music streaming or online multiplayer). - */ - private _isLoadingInForeground = true; + private privateResourceManager = new PrivateResourceManager(this); + private sceneResourceLoadingQueue = new ResourceLoadingQueue( + 'scene', + this.privateResourceManager, + /** shouldProcessResources= */ false, + ( + unloadedTaskIdentifier, + unloadedTaskState, + newTaskIdentifier, + newTaskState + ) => + this._getResourcesOnlyUsedInUnloadedScene( + unloadedTaskIdentifier, + unloadedTaskState, + newTaskIdentifier, + newTaskState + ) + ); + private objectResourceLoadingQueues = new Map< + string, + ResourceLoadingQueue + >(); /** * @param runtimeGame The game. @@ -185,7 +184,6 @@ namespace gdjs { layoutDataArray: Array ) { this._runtimeGame = runtimeGame; - this._resources = new Map(); this._globalResources = globalResources; // These 3 attributes are filled by `setResources`. @@ -241,19 +239,18 @@ namespace gdjs { ): void { this._globalResources = globalResources; - this._sceneLoadingStates.clear(); + // TODO We should probably instanciate new queues to avoid side effects from running tasks. + this.sceneResourceLoadingQueue.clear(); + for (const objectResourceLoadingQueue of this.objectResourceLoadingQueues.values()) { + objectResourceLoadingQueue.clear(); + } for (const layoutData of layoutDataArray) { - this._sceneLoadingStates.set(layoutData.name, { - resourceNames: layoutData.usedResources.map( - (resource) => resource.name - ), - status: 'not-loaded', - }); + this.sceneResourceLoadingQueue.registerResources( + layoutData.name, + layoutData.usedResources + ); } - // TODO Clearing the queue doesn't abort the running task, but it should - // not matter as resource loading is really fast in preview mode. - this._sceneToLoadQueue.length = 0; for (let index = layoutDataArray.length - 1; index >= 0; index--) { const layoutData = layoutDataArray[index]; @@ -264,11 +261,11 @@ namespace gdjs { : resourcesPreloading; if (resolvedResourcesPreloading === 'at-startup') { - this._sceneToLoadQueue.push(new SceneLoadingTask(layoutData.name)); + this.sceneResourceLoadingQueue.enqueue(layoutData.name); } } - this._resources.clear(); + this.privateResourceManager._resources.clear(); for (const resourceData of resourceDataArray) { if (!resourceData.file) { // Empty string or missing `file` field: not a valid resource, let's entirely ignore it. @@ -277,7 +274,10 @@ namespace gdjs { continue; } - this._resources.set(resourceData.name, resourceData); + this.privateResourceManager._resources.set( + resourceData.name, + resourceData + ); } } @@ -286,19 +286,20 @@ namespace gdjs { ): Promise { let loadedCount = 0; await ResourceLoader.processAndRetryIfNeededWithPromisePool( - [...this._resources.values()], + [...this.privateResourceManager._resources.values()], ResourceLoader.maxForegroundConcurrency, ResourceLoader.maxAttempt, async (resource) => { - await this._loadResource(resource); - await this._processResource(resource); + await this.privateResourceManager._loadResource(resource); + await this.privateResourceManager._processResource(resource); loadedCount++; - onProgress(loadedCount, this._resources.size); + onProgress(loadedCount, this.privateResourceManager._resources.size); } ); - for (const sceneLoadingState of this._sceneLoadingStates.values()) { - sceneLoadingState.status = 'ready'; + this.sceneResourceLoadingQueue.setAllResourcesAs('ready'); + for (const objectResourceLoadingQueue of this.objectResourceLoadingQueues.values()) { + objectResourceLoadingQueue.setAllResourcesAs('ready'); } } @@ -312,13 +313,14 @@ namespace gdjs { ResourceLoader.maxForegroundConcurrency, ResourceLoader.maxAttempt, async (resourceName) => { - const resource = this._resources.get(resourceName); + const resource = + this.privateResourceManager._resources.get(resourceName); if (resource) { - await this._loadResource(resource); - await this._processResource(resource); + await this.privateResourceManager._loadResource(resource); + await this.privateResourceManager._processResource(resource); } loadedCount++; - onProgress(loadedCount, this._resources.size); + onProgress(loadedCount, this.privateResourceManager._resources.size); } ); } @@ -330,8 +332,9 @@ namespace gdjs { firstSceneName: string, onProgress: (count: number, total: number) => void ): Promise { - const firstSceneState = this._sceneLoadingStates.get(firstSceneName); - if (!firstSceneState) { + const firstSceneResourceNames = + this.sceneResourceLoadingQueue.getResourceNamesFor(firstSceneName); + if (!firstSceneResourceNames) { logger.warn( 'Can\'t load resource for unknown scene: "' + firstSceneName + '".' ); @@ -341,26 +344,27 @@ namespace gdjs { let loadedCount = 0; const resourceNames = [ ...this._globalResources, - ...firstSceneState.resourceNames, + ...firstSceneResourceNames, ]; await ResourceLoader.processAndRetryIfNeededWithPromisePool( resourceNames, ResourceLoader.maxForegroundConcurrency, ResourceLoader.maxAttempt, async (resourceName) => { - const resource = this._resources.get(resourceName); + const resource = + this.privateResourceManager._resources.get(resourceName); if (!resource) { logger.warn('Unable to find resource "' + resourceName + '".'); return; } - await this._loadResource(resource); - await this._processResource(resource); + await this.privateResourceManager._loadResource(resource); + await this.privateResourceManager._processResource(resource); loadedCount++; onProgress(loadedCount, resourceNames.length); } ); - firstSceneState.status = 'ready'; + this.sceneResourceLoadingQueue.setResourcesAs(firstSceneName, 'ready'); } /** @@ -370,96 +374,7 @@ namespace gdjs { * scenes. */ async loadAllSceneInBackground(): Promise { - if (this.currentLoadingSceneName) { - return; - } - - debugLogger.log('Loading all scene resources, in background.'); - while (this._sceneToLoadQueue.length > 0) { - debugLogger.log( - `Still resources of ${this._sceneToLoadQueue.length} scene(s) to load: ${this._sceneToLoadQueue.map((task) => task.sceneName).join(', ')}` - ); - const task = this._sceneToLoadQueue[this._sceneToLoadQueue.length - 1]; - if (task === undefined) { - continue; - } - this.currentLoadingSceneName = task.sceneName; - if (!this.areSceneAssetsLoaded(task.sceneName)) { - debugLogger.log( - `Loading (but not processing) resources for scene ${task.sceneName}.` - ); - await this._doLoadSceneResources( - task.sceneName, - async (count, total) => task.onProgress(count, total) - ); - debugLogger.log( - `Done loading (but not processing) resources for scene ${task.sceneName}.` - ); - - // A scene may have been moved last while awaiting resources to be - // downloaded (see _prioritizeScene). - this._sceneToLoadQueue.splice( - this._sceneToLoadQueue.findIndex((element) => element === task), - 1 - ); - task.onFinish(); - } else { - this._sceneToLoadQueue.pop(); - } - } - debugLogger.log(`Scene resources loading finished.`); - this.currentLoadingSceneName = ''; - } - - private async _doLoadSceneResources( - sceneName: string, - onProgress?: (count: number, total: number) => Promise - ): Promise { - const sceneState = this._sceneLoadingStates.get(sceneName); - if (!sceneState) { - logger.warn( - 'Can\'t load resource for unknown scene: "' + sceneName + '".' - ); - return; - } - - let loadedCount = 0; - await ResourceLoader.processAndRetryIfNeededWithPromisePool( - sceneState.resourceNames, - this._isLoadingInForeground - ? ResourceLoader.maxForegroundConcurrency - : ResourceLoader.maxBackgroundConcurrency, - ResourceLoader.maxAttempt, - async (resourceName) => { - const resource = this._resources.get(resourceName); - if (!resource) { - logger.warn('Unable to find resource "' + resourceName + '".'); - return; - } - await this._loadResource(resource); - loadedCount++; - this.currentSceneLoadingProgress = - loadedCount / sceneState.resourceNames.length; - onProgress && - (await onProgress(loadedCount, sceneState.resourceNames.length)); - } - ); - sceneState.status = 'loaded'; - } - - private async _loadResource(resource: ResourceData): Promise { - const resourceManager = this._resourceManagersMap.get(resource.kind); - if (!resourceManager) { - logger.warn( - 'Unknown resource kind: "' + - resource.kind + - '" for: "' + - resource.name + - '".' - ); - return; - } - await resourceManager.loadResource(resource.name); + this.sceneResourceLoadingQueue.loadAllTasksInBackground(); } /** @@ -475,28 +390,10 @@ namespace gdjs { return; } await this.loadSceneResources(sceneName, onProgress); - - const sceneState = this._sceneLoadingStates.get(sceneName); - if (!sceneState) { - logger.warn( - 'Can\'t load resource for unknown scene: "' + sceneName + '".' - ); - return; - } - - let parsedCount = 0; - for (const resourceName of sceneState.resourceNames) { - const resource = this._resources.get(resourceName); - if (!resource) { - logger.warn('Unable to find resource "' + resourceName + '".'); - continue; - } - await this._processResource(resource); - parsedCount++; - onProgress && - (await onProgress(parsedCount, sceneState.resourceNames.length)); - } - sceneState.status = 'ready'; + await this.sceneResourceLoadingQueue.processResources( + sceneName, + onProgress + ); } /** @@ -514,11 +411,11 @@ namespace gdjs { `Prioritization of loading of resources for scene ${sceneName} was requested.` ); - this._isLoadingInForeground = true; - const task = this._prioritizeScene(sceneName); + this.sceneResourceLoadingQueue.isLoadingInForeground = true; + const task = this.sceneResourceLoadingQueue.prioritize(sceneName); return new Promise((resolve, reject) => { if (!task) { - this._isLoadingInForeground = false; + this.sceneResourceLoadingQueue.isLoadingInForeground = false; debugLogger.log( `Loading of resources for scene ${sceneName} was immediately resolved.` ); @@ -529,12 +426,73 @@ namespace gdjs { debugLogger.log( `Loading of resources for scene ${sceneName} just finished.` ); - this._isLoadingInForeground = false; + this.sceneResourceLoadingQueue.isLoadingInForeground = false; resolve(); }, onProgress); }); } + /** + * Preload an object assets in background. + */ + async loadObjectResources( + sceneName: string, + objectName: string, + usedResources: Array + ): Promise { + debugLogger.log( + `Loading of resources for object ${objectName} was requested.` + ); + const objectResourceLoadingQueue = + this.getObjectResourceLoadingQueue(sceneName); + objectResourceLoadingQueue.registerResources(objectName, usedResources); + const task = objectResourceLoadingQueue.enqueue(objectName); + objectResourceLoadingQueue.loadAllTasksInBackground(); + return new Promise((resolve, reject) => { + if (!task) { + debugLogger.log( + `Loading of resources for object ${objectName} was immediately resolved.` + ); + resolve(); + return; + } + task.registerCallback(() => { + debugLogger.log( + `Loading of resources for object ${objectName} just finished.` + ); + resolve(); + }); + }); + } + + private getObjectResourceLoadingQueue(sceneName: string) { + let objectResourceLoadingQueue = + this.objectResourceLoadingQueues.get(sceneName); + if (!objectResourceLoadingQueue) { + objectResourceLoadingQueue = new ResourceLoadingQueue( + `Independent objects of ${sceneName}`, + this.privateResourceManager, + /** shouldProcessResources= */ true, + ( + unloadedTaskIdentifier, + unloadedTaskState, + newTaskIdentifier, + newTaskState + ) => + this._getResourcesOnlyUsedInObject( + sceneName, + unloadedTaskIdentifier, + unloadedTaskState + ) + ); + this.objectResourceLoadingQueues.set( + sceneName, + objectResourceLoadingQueue + ); + } + return objectResourceLoadingQueue; + } + /** * To be called when the game is disposed. * Dispose all the resource managers. @@ -560,36 +518,47 @@ namespace gdjs { `Unloading of resources for scene ${unloadedSceneName} was requested.` ); - const sceneUniqueResourcesByKindMap = - this._getResourcesByKindOnlyUsedInUnloadedScene({ - unloadedSceneName, - newSceneName, - }); - - for (const [kindResourceManager, resourceManager] of this - ._resourceManagersMap) { - const resources = - sceneUniqueResourcesByKindMap.get(kindResourceManager); - if (resources) { - debugLogger.log( - `Unloading of resources of kind ${kindResourceManager} for scene ${unloadedSceneName}: `, - resources.map((resource) => resource.name).join(', ') - ); - for (const resource of resources) { - resourceManager.unloadResource(resource); - } - } - } + this.sceneResourceLoadingQueue.unloadResources( + unloadedSceneName, + newSceneName + ); + const objectResourceLoadingQueue = + this.getObjectResourceLoadingQueue(unloadedSceneName); + objectResourceLoadingQueue.clear(); debugLogger.log( `Unloading of resources for scene ${unloadedSceneName} finished.` ); + } - const sceneState = this._sceneLoadingStates.get(unloadedSceneName); - if (sceneState) { - sceneState.status = 'not-loaded'; + /** + * Unload an object assets in background. + */ + unloadObjectResources(sceneName: string, objectName: string): void { + const objectResourceLoadingQueue = + this.getObjectResourceLoadingQueue(sceneName); + if (!objectResourceLoadingQueue.areAssetsReady(objectName)) { + debugLogger.log( + `Can't unload of resources for object ${objectName} as it is not loaded.` + ); + return; } - // TODO: mark the scene as unloaded so it's not automatically loaded again eagerly. + for (const scene of this._runtimeGame._sceneStack.getAllScenes()) { + if (scene.getObjects(objectName).length > 0) { + debugLogger.log( + `Can't unload of resources for object ${objectName} as it still have instances living in the scene.` + ); + return; + } + } + debugLogger.log( + `Unloading of resources for object ${objectName} was requested.` + ); + objectResourceLoadingQueue.unloadResources(objectName); + objectResourceLoadingQueue.unregisterResources(objectName); + debugLogger.log( + `Unloading of resources for object ${objectName} finished.` + ); } /** @@ -597,84 +566,18 @@ namespace gdjs { */ 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 resource of this.privateResourceManager._resources.values()) { + this.privateResourceManager._unloadResource(resource.name); } - for (const sceneLoadingState of this._sceneLoadingStates.values()) { - sceneLoadingState.status = 'not-loaded'; + this.sceneResourceLoadingQueue.setAllResourcesAs('not-loaded'); + for (const objectResourceLoadingQueue of this.objectResourceLoadingQueues.values()) { + objectResourceLoadingQueue.clear(); } debugLogger.log(`Unloading of all resources finished.`); } - /** - * Put a given scene at the end of the queue. - * - * When the scene that is currently loading in background is done, - * this scene will be the next to be loaded. - */ - private _prioritizeScene(sceneName: string): SceneLoadingTask | null { - const sceneState = this._sceneLoadingStates.get(sceneName); - if (!sceneState) return null; - if (sceneState.status === 'loaded' || sceneState.status === 'ready') { - debugLogger.log( - `Scene ${sceneName} is already loaded. Skipping prioritization.` - ); - - // The scene is already loaded, nothing to do. - return null; - } - - // The scene is not loaded: either prioritize it or add it to the loading queue. - const taskIndex = this._sceneToLoadQueue.findIndex( - (task) => task.sceneName === sceneName - ); - let task: SceneLoadingTask; - if (taskIndex !== -1) { - // There is already a task for this scene in the queue. - // Move it so that it's loaded first. - task = this._sceneToLoadQueue[taskIndex]; - this._sceneToLoadQueue.splice(taskIndex, 1); - this._sceneToLoadQueue.push(task); - } else { - // There is no task for this scene in the queue. - // It might be because the scene was unloaded or never loaded. - // In this case, we need to add a new task to the queue. - task = new SceneLoadingTask(sceneName); - this._sceneToLoadQueue.push(task); - } - - // Re-start the loading process in the background. While at the beginning of the game - // it's not needed because already launched, a scene might be unloaded. This means - // that we then need to relaunch the loading process. - this.loadAllSceneInBackground(); - - return task; - } - - private async _processResource(resource: ResourceData): Promise { - const resourceManager = this._resourceManagersMap.get(resource.kind); - if (!resourceManager) { - logger.warn( - 'Unknown resource kind: "' + - resource.kind + - '" for: "' + - resource.name + - '".' - ); - return; - } - await resourceManager.processResource(resource.name); - } - getSceneLoadingProgress(sceneName: string): float { - return sceneName === this.currentLoadingSceneName - ? this.currentSceneLoadingProgress - : this.areSceneAssetsLoaded(sceneName) - ? 1 - : 0; + return this.sceneResourceLoadingQueue.getLoadingProgress(sceneName); } /** @@ -682,10 +585,7 @@ namespace gdjs { * (but maybe not parsed). */ areSceneAssetsLoaded(sceneName: string): boolean { - const sceneState = this._sceneLoadingStates.get(sceneName); - if (!sceneState) return false; - - return sceneState.status === 'loaded' || sceneState.status === 'ready'; + return this.sceneResourceLoadingQueue.areAssetsLoaded(sceneName); } /** @@ -693,14 +593,21 @@ namespace gdjs { * parsed. */ areSceneAssetsReady(sceneName: string): boolean { - const sceneState = this._sceneLoadingStates.get(sceneName); - if (!sceneState) return false; + return this.sceneResourceLoadingQueue.areAssetsReady(sceneName); + } - return sceneState.status === 'ready'; + /** + * @returns true when all the resources of the given object are loaded and + * parsed. + */ + areObjectAssetsReady(sceneName: string, objectName: string): boolean { + return this.getObjectResourceLoadingQueue(sceneName).areAssetsReady( + objectName + ); } getResource(resourceName: string): ResourceData | null { - return this._resources.get(resourceName) || null; + return this.privateResourceManager._resources.get(resourceName) || null; } // Helper methods used when resources are loaded from an URL. @@ -849,60 +756,109 @@ namespace gdjs { } /** - * Get the map of resources that are only used in the scene that is being unloaded, + * Get the resources that are only used in the scene that is being unloaded, * and that are not used in any other loaded scene (or the scene that is coming next). */ - private _getResourcesByKindOnlyUsedInUnloadedScene({ - unloadedSceneName, - newSceneName, - }: { - unloadedSceneName: string; - newSceneName: string | null; - }): Map { - const unloadedSceneState = - this._sceneLoadingStates.get(unloadedSceneName); - if (!unloadedSceneState) { - return new Map(); - } - + private _getResourcesOnlyUsedInUnloadedScene( + unloadedSceneName: string, + unloadedSceneState: LoadingTaskState, + newSceneName: string | null, + newSceneState: LoadingTaskState | null + ): Set { // Construct the set of all resources to unload. These are the resources // used in the scene that is being unloaded minus all the resources used // by the other scenes that are loaded (and the possible scene that is coming next). const resourceNamesToUnload = new Set( unloadedSceneState.resourceNames ); - for (const [ - sceneName, - sceneState, - ] of this._sceneLoadingStates.entries()) { - if (sceneName === unloadedSceneName) continue; - - if ( - sceneName === newSceneName || - sceneState.status === 'loaded' || - sceneState.status === 'ready' - ) { - sceneState.resourceNames.forEach((resourceName) => { - resourceNamesToUnload.delete(resourceName); - }); + // Also add the resources manually loaded for objects during the current scene. + // TODO Abort loading task to avoid to leave resources from an object that is currently loading. + const unloadedSceneObjectResourceLoadingQueue = newSceneName + ? this.getObjectResourceLoadingQueue(newSceneName) + : null; + if (unloadedSceneObjectResourceLoadingQueue) { + for (const objectLoadingState of unloadedSceneObjectResourceLoadingQueue.loadingStates.values()) { + for (const resourceName of objectLoadingState.resourceNames) { + resourceNamesToUnload.add(resourceName); + } } } + for (const sceneLoadingState of this.sceneResourceLoadingQueue.loadingStates.values()) { + if (sceneLoadingState === unloadedSceneState) continue; - const result = new Map(); - resourceNamesToUnload.forEach((resourceName) => { - const resourceData = this._resources.get(resourceName); - if (!resourceData) return; - - const kind = resourceData.kind; - const resources = result.get(kind); - if (resources) { - resources.push(resourceData); - } else { - result.set(kind, [resourceData]); + if ( + // We don't unload resources when the next scene need them too. + sceneLoadingState === newSceneState || + // We don't unload resources used by any loaded scene. + sceneLoadingState.status === 'loaded' || + sceneLoadingState.status === 'ready' + ) { + for (const resourceName of sceneLoadingState.resourceNames) { + resourceNamesToUnload.delete(resourceName); + } } - }); + } + // Other scenes from the stack may have loaded objects which use the same resources. + for (const objectResourceLoadingQueue of this.objectResourceLoadingQueues.values()) { + if ( + objectResourceLoadingQueue !== unloadedSceneObjectResourceLoadingQueue + ) { + for (const objectLoadingState of objectResourceLoadingQueue.loadingStates.values()) { + for (const resourceName of objectLoadingState.resourceNames) { + resourceNamesToUnload.delete(resourceName); + } + } + } + } + return resourceNamesToUnload; + } - return result; + private _getResourcesOnlyUsedInObject( + currentSceneName: string, + objectName: string, + objectLoadingState: LoadingTaskState + ): Set { + const resourceNamesToUnload = new Set( + objectLoadingState.resourceNames + ); + const currentSceneObjectResourceLoadingQueue = + this.getObjectResourceLoadingQueue(currentSceneName); + // The resources used by the current scene are already excluded from the + // object resources list at export. + // Other manually loaded objects may use the same resources. + for (const otherObjectLoadingState of currentSceneObjectResourceLoadingQueue.loadingStates.values()) { + if (otherObjectLoadingState === objectLoadingState) { + continue; + } + for (const resourceName of otherObjectLoadingState.resourceNames) { + resourceNamesToUnload.delete(resourceName); + } + } + // Other scenes may use the same resources. + for (const sceneLoadingState of this.sceneResourceLoadingQueue.loadingStates.values()) { + if ( + // We don't unload resources used by any loaded scene. + sceneLoadingState.status === 'loaded' || + sceneLoadingState.status === 'ready' + ) { + for (const resourceName of sceneLoadingState.resourceNames) { + resourceNamesToUnload.delete(resourceName); + } + } + } + // Other scenes from the stack may have loaded objects which use the same resources. + for (const objectResourceLoadingQueue of this.objectResourceLoadingQueues.values()) { + if ( + objectResourceLoadingQueue !== currentSceneObjectResourceLoadingQueue + ) { + for (const objectLoadingState of objectResourceLoadingQueue.loadingStates.values()) { + for (const resourceName of objectLoadingState.resourceNames) { + resourceNamesToUnload.delete(resourceName); + } + } + } + } + return resourceNamesToUnload; } static processWithPromisePool( @@ -973,4 +929,436 @@ namespace gdjs { return output; } } + + /** + * Give `ResourceLoadingQueue` access to private members of `ResourceManager` + * without exposing them outside. + */ + class PrivateResourceManager { + private resourceLoader: ResourceLoader; + + /** + * All the resource of a game by resource name. + */ + _resources = new Map(); + + constructor(resourceLoader: ResourceLoader) { + this.resourceLoader = resourceLoader; + } + + async _processResource(resource: ResourceData): Promise { + const resourceManager = this.resourceLoader._resourceManagersMap.get( + resource.kind + ); + if (!resourceManager) { + logger.warn( + 'Unknown resource kind: "' + + resource.kind + + '" for: "' + + resource.name + + '".' + ); + return; + } + await resourceManager.processResource(resource.name); + } + + async _loadResource(resource: ResourceData): Promise { + const resourceManager = this.resourceLoader._resourceManagersMap.get( + resource.kind + ); + if (!resourceManager) { + logger.warn( + 'Unknown resource kind: "' + + resource.kind + + '" for: "' + + resource.name + + '".' + ); + return; + } + await resourceManager.loadResource(resource.name); + } + + _unloadResource(resourceName: string): void { + const resourceData = this._resources.get(resourceName); + if (resourceData) { + const resourceManager = this.resourceLoader._resourceManagersMap.get( + resourceData.kind + ); + if (resourceManager) { + debugLogger.log( + `Unloading of resources of kind ${resourceData.kind} : ${resourceName}` + ); + resourceManager.unloadResource(resourceData); + } + } + } + } + + type ResourceDifferenceOperation = ( + unloadedTaskIdentifier: string, + unloadedTaskState: LoadingTaskState, + newTaskIdentifier: string | null, + newTaskState: LoadingTaskState | null + ) => Set; + + class ResourceLoadingQueue { + private resourceLoader: PrivateResourceManager; + private name: string; + /** + * The name of the task for which resources are currently being loaded. + */ + private currentLoadingTaskIdentifier: string = ''; + + /** + * The progress, between 0 and 1, of the loading of the resource, for the + * task that is being loaded (see `currentLoadingTaskIdentifier`). + */ + private currentTaskProgress: float = 0; + + /** + * Resources and the loading state of each task, indexed by task identifier. + * + * Don't change its state outside of `ResourceLoadingQueue`. + */ + loadingStates = new Map(); + + /** + * A queue of loading task whose resources are still to be pre-loaded. + */ + private loadingTaskQueue: Array = new Array(); + + /** + * It's set to `true` during intermediary loading screen to use a greater + * concurrency as the game is paused and doesn't need bandwidth (for video + * or music streaming or online multiplayer). + */ + isLoadingInForeground = true; + + private getResourcesDifference: ResourceDifferenceOperation; + private shouldProcessResources: boolean; + + constructor( + name: string, + resourceLoader: PrivateResourceManager, + shouldProcessResources: boolean, + getResourcesDifference: ResourceDifferenceOperation + ) { + this.name = name; + this.resourceLoader = resourceLoader; + this.shouldProcessResources = shouldProcessResources; + this.getResourcesDifference = getResourcesDifference; + } + + /** + * Load each task in order. + */ + async loadAllTasksInBackground(): Promise { + if (this.currentLoadingTaskIdentifier) { + return; + } + + debugLogger.log(`Loading all ${this.name} resources, in background.`); + while (this.loadingTaskQueue.length > 0) { + debugLogger.log( + `Still resources of ${this.loadingTaskQueue.length} ${this.name}(s) to load: ${this.loadingTaskQueue.map((task) => task.identifier).join(', ')}` + ); + const task = this.loadingTaskQueue[this.loadingTaskQueue.length - 1]; + if (task === undefined) { + continue; + } + this.currentLoadingTaskIdentifier = task.identifier; + if (!this.areAssetsLoaded(task.identifier)) { + debugLogger.log( + `Loading (but not processing) resources for ${this.name} ${task.identifier}.` + ); + const loadingState = this.loadingStates.get(task.identifier); + if (loadingState) { + await this._doLoadResources(loadingState, async (count, total) => + task.onProgress(count, total) + ); + } else { + logger.warn( + `Can\'t load resource for unknown ${this.name}: "${task.identifier}".` + ); + return; + } + debugLogger.log( + `Done loading (but not processing) resources for ${this.name} ${task.identifier}.` + ); + + // A task may have been moved last while awaiting resources to be + // downloaded (see _prioritize). + this.loadingTaskQueue.splice( + this.loadingTaskQueue.findIndex((element) => element === task), + 1 + ); + task.onFinish(); + } else { + this.loadingTaskQueue.pop(); + } + } + debugLogger.log(`${this.name} resources loading finished.`); + this.currentLoadingTaskIdentifier = ''; + } + + private async _doLoadResources( + loadingState: LoadingTaskState, + onProgress?: (count: number, total: number) => Promise + ): Promise { + let loadedCount = 0; + await ResourceLoader.processAndRetryIfNeededWithPromisePool( + loadingState.resourceNames, + this.isLoadingInForeground + ? ResourceLoader.maxForegroundConcurrency + : ResourceLoader.maxBackgroundConcurrency, + ResourceLoader.maxAttempt, + async (resourceName) => { + const resource = this.resourceLoader._resources.get(resourceName); + if (!resource) { + logger.warn('Unable to find resource "' + resourceName + '".'); + return; + } + await this.resourceLoader._loadResource(resource); + if (this.shouldProcessResources) { + await this.resourceLoader._processResource(resource); + } + loadedCount++; + this.currentTaskProgress = + loadedCount / loadingState.resourceNames.length; + onProgress && + (await onProgress(loadedCount, loadingState.resourceNames.length)); + } + ); + loadingState.status = this.shouldProcessResources ? 'ready' : 'loaded'; + } + + /** + * Process resources that are needed right away. + */ + async processResources( + taskIdentifier: string, + onProgress?: (count: number, total: number) => Promise + ): Promise { + const loadingState = this.loadingStates.get(taskIdentifier); + if (!loadingState) { + logger.warn( + `Can\'t load resource for unknown ${this.name}: "${taskIdentifier}".` + ); + return; + } + if (loadingState.status !== 'loaded') { + logger.warn( + `Resources are not loaded can\'t process them: "${taskIdentifier}".` + ); + return; + } + + let parsedCount = 0; + for (const resourceName of loadingState.resourceNames) { + const resource = this.resourceLoader._resources.get(resourceName); + if (!resource) { + logger.warn('Unable to find resource "' + resourceName + '".'); + continue; + } + await this.resourceLoader._processResource(resource); + parsedCount++; + onProgress && + (await onProgress(parsedCount, loadingState.resourceNames.length)); + } + loadingState.status = 'ready'; + } + + /** + * Put a given scene at the end of the queue. + * + * When the scene that is currently loading in background is done, + * this scene will be the next to be loaded. + */ + prioritize(taskIdentifier: string): LoadingTask | null { + const loadingState = this.loadingStates.get(taskIdentifier); + if (!loadingState) return null; + if (loadingState.status === 'loaded' || loadingState.status === 'ready') { + debugLogger.log( + `Scene ${taskIdentifier} is already loaded. Skipping prioritization.` + ); + + // The scene is already loaded, nothing to do. + return null; + } + + // The scene is not loaded: either prioritize it or add it to the loading queue. + const taskIndex = this.loadingTaskQueue.findIndex( + (task) => task.identifier === taskIdentifier + ); + let task: LoadingTask; + if (taskIndex !== -1) { + // There is already a task for this scene in the queue. + // Move it so that it's loaded first. + task = this.loadingTaskQueue[taskIndex]; + this.loadingTaskQueue.splice(taskIndex, 1); + this.loadingTaskQueue.push(task); + } else { + // There is no task for this scene in the queue. + // It might be because the scene was unloaded or never loaded. + // In this case, we need to add a new task to the queue. + task = new LoadingTask(taskIdentifier); + this.loadingTaskQueue.push(task); + } + + // Re-start the loading process in the background. While at the beginning of the game + // it's not needed because already launched, a scene might be unloaded. This means + // that we then need to relaunch the loading process. + this.loadAllTasksInBackground(); + + return task; + } + + registerResources( + taskIdentifier: string, + usedResources: Array + ) { + const objectLoadingState = this.loadingStates.get(taskIdentifier); + if (objectLoadingState) { + debugLogger.log( + `${this.name} ${taskIdentifier} is already registered.` + ); + return; + } + this.loadingStates.set(taskIdentifier, { + resourceNames: usedResources.map((resource) => resource.name), + status: 'not-loaded', + }); + } + + unregisterResources(taskIdentifier: string) { + this.loadingStates.delete(taskIdentifier); + } + + /** + * Add a task without starting it. + */ + enqueue(taskIdentifier: string): LoadingTask | null { + const objectLoadingState = this.loadingStates.get(taskIdentifier); + if (!objectLoadingState) { + debugLogger.log( + `Resources for ${this.name} ${taskIdentifier} are not registered.` + ); + return null; + } + if (objectLoadingState.status !== 'not-loaded') { + debugLogger.log( + `Resources for ${this.name} ${taskIdentifier} are already loading or loaded.` + ); + return null; + } + debugLogger.log( + `Loading of resources for ${this.name} ${taskIdentifier} was requested.` + ); + const task = new LoadingTask(taskIdentifier); + this.loadingTaskQueue.push(task); + return task; + } + + unloadResources( + unloadedTaskIdentifier: string, + newTaskIdentifier: string | null = null + ): void { + if (unloadedTaskIdentifier === newTaskIdentifier) { + return; + } + if (!unloadedTaskIdentifier) return; + debugLogger.log( + `Unloading of resources for ${this.name} ${unloadedTaskIdentifier} was requested.` + ); + + const unloadedTaskState = this.loadingStates.get(unloadedTaskIdentifier); + const newTaskState = newTaskIdentifier + ? this.loadingStates.get(newTaskIdentifier) || null + : null; + if (!unloadedTaskState) { + return; + } + for (const resourceName of this.getResourcesDifference( + unloadedTaskIdentifier, + unloadedTaskState, + newTaskIdentifier, + newTaskState + )) { + this.resourceLoader._unloadResource(resourceName); + } + + debugLogger.log( + `Unloading of resources for ${this.name} ${unloadedTaskIdentifier} finished.` + ); + + unloadedTaskState.status = 'not-loaded'; + // TODO: mark the scene as unloaded so it's not automatically loaded again eagerly. + } + + /** + * @returns true when all the resources of the given task are loaded + * (but maybe not parsed). + */ + areAssetsLoaded(taskIdentifier: string): boolean { + const loadingState = this.loadingStates.get(taskIdentifier); + if (!loadingState) return false; + + return ( + loadingState.status === 'loaded' || loadingState.status === 'ready' + ); + } + + /** + * @returns true when all the resources of the given task are loaded and + * parsed. + */ + areAssetsReady(taskIdentifier: string): boolean { + const loadingState = this.loadingStates.get(taskIdentifier); + if (!loadingState) return false; + + return loadingState.status === 'ready'; + } + + setAllResourcesAs(status: 'not-loaded' | 'loaded' | 'ready'): void { + for (const loadingState of this.loadingStates.values()) { + loadingState.status = status; + } + } + + setResourcesAs( + taskIdentifier: string, + status: 'not-loaded' | 'loaded' | 'ready' + ): void { + const loadingState = this.loadingStates.get(taskIdentifier); + if (!loadingState) { + return; + } + loadingState.status = status; + } + + getResourceNamesFor(taskIdentifier: string): Array | null { + const loadingState = this.loadingStates.get(taskIdentifier); + if (!loadingState) { + return null; + } + return loadingState.resourceNames; + } + + getLoadingProgress(taskIdentifier: string): float { + return taskIdentifier === this.currentLoadingTaskIdentifier + ? this.currentTaskProgress + : this.areAssetsLoaded(taskIdentifier) + ? 1 + : 0; + } + + clear() { + this.loadingStates.clear(); + // TODO Clearing the queue doesn't abort the running task, but it should + // not matter as resource loading is really fast in preview mode. + this.loadingTaskQueue.length = 0; + } + } } diff --git a/GDJS/Runtime/events-tools/runtimescenetools.ts b/GDJS/Runtime/events-tools/runtimescenetools.ts index ed1f6d800..6380d2468 100644 --- a/GDJS/Runtime/events-tools/runtimescenetools.ts +++ b/GDJS/Runtime/events-tools/runtimescenetools.ts @@ -363,6 +363,38 @@ namespace gdjs { ): boolean => { return runtimeScene.getGame().areSceneAssetsLoaded(sceneName); }; + + /** + * Preload an object assets in background. + */ + export const loadObjectOrGroupAssets = ( + runtimeScene: gdjs.RuntimeScene, + objectOrGroupName: string + ): void => { + runtimeScene.getGame().loadObjectOrGroupAssets(objectOrGroupName); + }; + + /** + * Unload an object assets. + */ + export const unloadObjectOrGroupAssets = ( + runtimeScene: gdjs.RuntimeScene, + objectOrGroupName: string + ): void => { + runtimeScene.getGame().unloadObjectOrGroupAssets(objectOrGroupName); + }; + + /** + * Check if object assets have finished to load in background. + */ + export const areObjectOrGroupAssetsLoaded = ( + runtimeScene: gdjs.RuntimeScene, + objectOrGroupName: string + ): boolean => { + return runtimeScene + .getGame() + .areObjectOrGroupAssetsLoaded(objectOrGroupName); + }; } } } diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index 9ac3e14f4..169137fcd 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -914,6 +914,116 @@ namespace gdjs { return this._hasJustResumed; } + /** + * Preload an object assets in background. + */ + loadObjectOrGroupAssets(objectOrGroupName: string): void { + const currentScene = this._sceneStack.getCurrentScene(); + if (!currentScene) { + return; + } + const objectGroupData = this.getObjectGroupData( + currentScene.getName(), + objectOrGroupName + ); + if (objectGroupData) { + for (const object of objectGroupData.objects) { + this._loadObjectAssets(currentScene, object.name); + } + } else { + this._loadObjectAssets(currentScene, objectOrGroupName); + } + } + + private _loadObjectAssets(currentScene: RuntimeScene, objectName: string) { + const objectData = currentScene._objects.get(objectName); + if (!objectData) { + return; + } + const usedResources = objectData.usedResources; + if (!usedResources) { + return; + } + this._resourcesLoader.loadObjectResources( + currentScene.getName(), + objectName, + usedResources + ); + } + + /** + * @returns true when all the resources of the given object are loaded. + */ + areObjectOrGroupAssetsLoaded(objectOrGroupName: string): boolean { + const currentScene = this._sceneStack.getCurrentScene(); + if (!currentScene) { + return false; + } + const objectGroupData = this.getObjectGroupData( + currentScene.getName(), + objectOrGroupName + ); + if (objectGroupData) { + for (const object of objectGroupData.objects) { + if ( + !this._resourcesLoader.areObjectAssetsReady( + currentScene.getName(), + object.name + ) + ) { + return false; + } + } + return true; + } + return this._resourcesLoader.areObjectAssetsReady( + currentScene.getName(), + objectOrGroupName + ); + } + + /** + * Unload an object assets. + */ + unloadObjectOrGroupAssets(objectOrGroupName: string): void { + const currentScene = this._sceneStack.getCurrentScene(); + if (!currentScene) { + return; + } + const objectGroupData = this.getObjectGroupData( + currentScene.getName(), + objectOrGroupName + ); + if (objectGroupData) { + for (const object of objectGroupData.objects) { + this._resourcesLoader.unloadObjectResources( + currentScene.getName(), + object.name + ); + } + } else { + this._resourcesLoader.unloadObjectResources( + currentScene.getName(), + objectOrGroupName + ); + } + } + + private getObjectGroupData( + sceneName: string, + objectGroupName: string + ): ObjectGroupData | null { + const sceneData = this.getSceneData(sceneName); + if (sceneData) { + for (const objectGroup of sceneData.objectsGroups) { + if (objectGroup.name === objectGroupName) { + return objectGroup; + } + } + } + return null; + } + /** * Preload a scene assets as soon as possible in background. */ diff --git a/GDJS/Runtime/types/project-data.d.ts b/GDJS/Runtime/types/project-data.d.ts index c807c70d2..e8d4a6a8c 100644 --- a/GDJS/Runtime/types/project-data.d.ts +++ b/GDJS/Runtime/types/project-data.d.ts @@ -38,6 +38,17 @@ declare type ObjectData = { behaviors: Array; /** The list of effects. */ effects: Array; + /** + * The resources used by this object excluding the one already used by the + * scene and/or the project. + * This attribute is only set for objects that are preloaded manually. + */ + usedResources?: ResourceReference[]; +}; + +declare type ObjectGroupData = { + name: string; + objects: Array<{ name: string }>; }; declare type GetNetworkSyncDataOptions = { @@ -277,6 +288,7 @@ declare interface InstanceContainerData { variables: RootVariableData[]; instances: InstanceData[]; objects: ObjectData[]; + objectsGroups: ObjectGroupData[]; layers: LayerData[]; } diff --git a/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js b/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js index ce329e7cd..33bb204fe 100644 --- a/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js +++ b/GDJS/tests/tests-utils/init.pixiruntimegamewithassets.js @@ -91,6 +91,7 @@ gdjs.getPixiRuntimeGameWithAssets = (props = null) => { mangledName: '', name: '', objects: [], + objectsGroups: [], layers: [], instances: [], behaviorsSharedData: [], diff --git a/GDJS/tests/tests/ResourceLoader.js b/GDJS/tests/tests/ResourceLoader.js index 5dd686746..f1d99d5f7 100644 --- a/GDJS/tests/tests/ResourceLoader.js +++ b/GDJS/tests/tests/ResourceLoader.js @@ -6,15 +6,33 @@ describe('gdjs.ResourceLoader', () => { const delay = (ms) => new Promise((res) => setTimeout(res, ms)); - /** @returns {LayoutData} */ - const createSceneData = (name, usedResources) => { + /** @typedef {{name: string, usedResources: Array}} ResourcesContainer */ + /** + * @param {ResourcesContainer & {objects?: Array}} props + * @returns {LayoutData} + */ + const createSceneData = ({ name, usedResources, objects }) => { return { r: 0, v: 0, b: 0, mangledName: name, name, - objects: [], + objects: objects + ? objects.map((object) => ({ + ...object, + type: '', + variables: [], + behaviors: [], + effects: [], + })) + : [], + objectsGroups: [ + { + name: 'MyGroup', + objects: [{ name: 'Object1' }, { name: 'Object2' }], + }, + ], layers: [], instances: [], behaviorsSharedData: [], @@ -34,25 +52,50 @@ describe('gdjs.ResourceLoader', () => { gridColor: 0, gridAlpha: 1, snap: false, - } + }, }; }; /** @type {{layouts?: LayoutData[], resources?: ResourcesData}} */ const gameSettingsWithThreeScenes = { layouts: [ - createSceneData('Scene1', [ - { name: 'scene1-resource1.png' }, - { name: 'scene1-resource2.png' }, - ]), - createSceneData('Scene2', [ - { name: 'scene2-resource1.png' }, - { name: 'shared-resource.png' }, - ]), - createSceneData('Scene3', [ - { name: 'scene3-resource1.png' }, - { name: 'shared-resource.png' }, - ]), + createSceneData({ + name: 'Scene1', + usedResources: [ + { name: 'scene1-resource1.png' }, + { name: 'scene1-resource2.png' }, + ], + objects: [ + { + name: 'Object1', + usedResources: [ + { name: 'scene1-object1-resource1.png' }, + { name: 'scene1-object1-resource2.png' }, + ], + }, + { + name: 'Object2', + usedResources: [ + { name: 'scene1-object2-resource1.png' }, + { name: 'scene1-object2-resource2.png' }, + ], + }, + ], + }), + createSceneData({ + name: 'Scene2', + usedResources: [ + { name: 'scene2-resource1.png' }, + { name: 'shared-resource.png' }, + ], + }), + createSceneData({ + name: 'Scene3', + usedResources: [ + { name: 'scene3-resource1.png' }, + { name: 'shared-resource.png' }, + ], + }), ], resources: { resources: [ @@ -70,6 +113,34 @@ describe('gdjs.ResourceLoader', () => { file: 'scene1-resource2.png', userAdded: true, }, + { + kind: 'fake-resource-kind-for-testing-only', + name: 'scene1-object1-resource1.png', + metadata: '', + file: 'scene1-object1-resource1.png', + userAdded: true, + }, + { + kind: 'fake-resource-kind-for-testing-only', + name: 'scene1-object1-resource2.png', + metadata: '', + file: 'scene1-object1-resource2.png', + userAdded: true, + }, + { + kind: 'fake-resource-kind-for-testing-only', + name: 'scene1-object2-resource1.png', + metadata: '', + file: 'scene1-object2-resource1.png', + userAdded: true, + }, + { + kind: 'fake-resource-kind-for-testing-only', + name: 'scene1-object2-resource2.png', + metadata: '', + file: 'scene1-object2-resource2.png', + userAdded: true, + }, { kind: 'fake-resource-kind-for-testing-only', name: 'scene2-resource1.png', @@ -357,4 +428,144 @@ describe('gdjs.ResourceLoader', () => { // Progress should be complete (1.0) expect(resourceLoader.getSceneLoadingProgress('Scene2')).to.be(1); }); + + /** + * @param {gdjs.RuntimeGame} runtimeGame + * @param {gdjs.MockedResourceManager} mockedResourceManager + */ + const loadObject1AndCheck = async (runtimeGame, mockedResourceManager) => { + runtimeGame.loadObjectOrGroupAssets('Object1'); + + // Object1 resources should be pending download + expect( + mockedResourceManager.isResourceDownloadPending( + 'scene1-object1-resource1.png' + ) + ).to.be(true); + expect( + mockedResourceManager.isResourceDownloadPending( + 'scene1-object1-resource2.png' + ) + ).to.be(true); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object1')).to.be(false); + + // Mark Object1 resources as loaded + mockedResourceManager.markPendingResourcesAsLoaded( + 'scene1-object1-resource1.png' + ); + mockedResourceManager.markPendingResourcesAsLoaded( + 'scene1-object1-resource2.png' + ); + await delay(10); + + // Object1 should now be ready + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object1')).to.be(true); + }; + + it('can load object resources with an action', async () => { + const mockedResourceManager = new gdjs.MockedResourceManager(); + const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithThreeScenes); + const resourceLoader = runtimeGame.getResourceLoader(); + resourceLoader.injectMockResourceManagerForTesting( + 'fake-resource-kind-for-testing-only', + mockedResourceManager + ); + + // Start loading first scene and background loading + runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene1'); + // Mark Scene1 resources as loaded + mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource1.png'); + mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource2.png'); + await delay(10); + + // Scene1 should now be ready + expect(runtimeGame.areSceneAssetsLoaded('Scene1')).to.be(true); + expect(runtimeGame.areSceneAssetsReady('Scene1')).to.be(true); + + runtimeGame._sceneStack.push('Scene1'); + await loadObject1AndCheck(runtimeGame, mockedResourceManager); + + runtimeGame.unloadObjectOrGroupAssets('Object1'); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object1')).to.be(false); + + await loadObject1AndCheck(runtimeGame, mockedResourceManager); + }); + + it('can load object group resources with an action', async () => { + const mockedResourceManager = new gdjs.MockedResourceManager(); + const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithThreeScenes); + const resourceLoader = runtimeGame.getResourceLoader(); + resourceLoader.injectMockResourceManagerForTesting( + 'fake-resource-kind-for-testing-only', + mockedResourceManager + ); + + // Start loading first scene and background loading + runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene1'); + // Mark Scene1 resources as loaded + mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource1.png'); + mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource2.png'); + await delay(10); + + // Scene1 should now be ready + expect(runtimeGame.areSceneAssetsLoaded('Scene1')).to.be(true); + expect(runtimeGame.areSceneAssetsReady('Scene1')).to.be(true); + + runtimeGame._sceneStack.push('Scene1'); + runtimeGame.loadObjectOrGroupAssets('MyGroup'); + + // Object1 resources should be pending download + expect( + mockedResourceManager.isResourceDownloadPending( + 'scene1-object1-resource1.png' + ) + ).to.be(true); + expect( + mockedResourceManager.isResourceDownloadPending( + 'scene1-object1-resource2.png' + ) + ).to.be(true); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object1')).to.be(false); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('MyGroup')).to.be(false); + + // Mark Object1 resources as loaded + mockedResourceManager.markPendingResourcesAsLoaded( + 'scene1-object1-resource1.png' + ); + mockedResourceManager.markPendingResourcesAsLoaded( + 'scene1-object1-resource2.png' + ); + await delay(10); + + // Object1 should now be ready + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object1')).to.be(true); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('MyGroup')).to.be(false); + + // Object2 resources should be pending download + expect( + mockedResourceManager.isResourceDownloadPending( + 'scene1-object2-resource1.png' + ) + ).to.be(true); + expect( + mockedResourceManager.isResourceDownloadPending( + 'scene1-object2-resource2.png' + ) + ).to.be(true); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object2')).to.be(false); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('MyGroup')).to.be(false); + + mockedResourceManager.markPendingResourcesAsLoaded( + 'scene1-object2-resource1.png' + ); + mockedResourceManager.markPendingResourcesAsLoaded( + 'scene1-object2-resource2.png' + ); + await delay(10); + + // Object1 and Object2 should now be ready + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object1')).to.be(true); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('Object2')).to.be(true); + expect(runtimeGame.areObjectOrGroupAssetsLoaded('MyGroup')).to.be(true); + }); }); diff --git a/GDJS/tests/tests/effects.js b/GDJS/tests/tests/effects.js index 69f759536..d300246bd 100644 --- a/GDJS/tests/tests/effects.js +++ b/GDJS/tests/tests/effects.js @@ -105,6 +105,7 @@ describe('gdjs.EffectsManager', () => { title: '', behaviorsSharedData: [], objects: [], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/GDJS/tests/tests/hot-reloader.js b/GDJS/tests/tests/hot-reloader.js index a2402f76f..5d256991d 100644 --- a/GDJS/tests/tests/hot-reloader.js +++ b/GDJS/tests/tests/hot-reloader.js @@ -182,6 +182,7 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => { objects: objects || [ createSpriteData({ name: 'MyObject', image: 'ResourceA' }), ], + objectsGroups: [], instances: instances ? instances.map((instance) => ({ ...defaultInstance, ...instance })) : [], @@ -221,6 +222,7 @@ describe('gdjs.HotReloader._hotReloadRuntimeGame', () => { objects: objects || [ createSpriteData({ name: 'MyChildObject', image: 'ResourceA' }), ], + objectsGroups: [], layers: [baseLayer], areaMinX: 0, areaMinY: 0, diff --git a/GDJS/tests/tests/runtimescene.js b/GDJS/tests/tests/runtimescene.js index ac2896cbe..e3b773b2d 100644 --- a/GDJS/tests/tests/runtimescene.js +++ b/GDJS/tests/tests/runtimescene.js @@ -45,6 +45,7 @@ describe('gdjs.RuntimeScene integration tests', function () { effects: [], }, ], + objectsGroups: [], instances: [], usedResources: [], uiSettings: { diff --git a/GDJS/tests/tests/scenestack.js b/GDJS/tests/tests/scenestack.js index d31880fa9..9ddd78ad3 100644 --- a/GDJS/tests/tests/scenestack.js +++ b/GDJS/tests/tests/scenestack.js @@ -14,6 +14,7 @@ describe('gdjs.SceneStack', () => { mangledName: name, name, objects: [], + objectsGroups: [], layers: [], instances: [], behaviorsSharedData: [], diff --git a/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 319db7553..fb0200af0 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -914,6 +914,8 @@ interface gdObject { [Const, Ref] DOMString GetAssetStoreId(); void SetType([Const] DOMString type); [Const, Ref] DOMString GetType(); + void SetResourcesPreloading([Const] DOMString value); + [Const, Ref] DOMString GetResourcesPreloading(); [Ref] ObjectConfiguration GetConfiguration(); diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index b941c9f28..507283512 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -791,6 +791,8 @@ export class gdObject extends EmscriptenObject { getAssetStoreId(): string; setType(type: string): void; getType(): string; + setResourcesPreloading(value: string): void; + getResourcesPreloading(): string; getConfiguration(): ObjectConfiguration; getVariables(): VariablesContainer; getEffects(): EffectsContainer; diff --git a/GDevelop.js/types/gdobject.js b/GDevelop.js/types/gdobject.js index fb2b809ff..80cdc698f 100644 --- a/GDevelop.js/types/gdobject.js +++ b/GDevelop.js/types/gdobject.js @@ -8,6 +8,8 @@ declare class gdObject { getAssetStoreId(): string; setType(type: string): void; getType(): string; + setResourcesPreloading(value: string): void; + getResourcesPreloading(): string; getConfiguration(): gdObjectConfiguration; getVariables(): gdVariablesContainer; getEffects(): gdEffectsContainer; diff --git a/newIDE/app/src/CompactPropertiesEditor/CompactPropertiesEditorByVisibility.js b/newIDE/app/src/CompactPropertiesEditor/CompactPropertiesEditorByVisibility.js index 29fc1c940..5d3bfbbc7 100644 --- a/newIDE/app/src/CompactPropertiesEditor/CompactPropertiesEditorByVisibility.js +++ b/newIDE/app/src/CompactPropertiesEditor/CompactPropertiesEditorByVisibility.js @@ -156,7 +156,7 @@ export const CompactCollapsibleAdvancedSection = ({ uncollapsedByDefault ); return showAdvancedOptions ? ( - + {children} - + ) : ( ); +const resourcesPreloadingFieldChoices: Array = [ + { + value: 'with-scene', + label: t`Preload with the scene`, + }, + { + value: 'manually', + label: t`Preload with an action`, + }, +]; +const getResourcesPreloadingField = ({ i18n }: {| i18n: I18nType |}): Field => { + return { + name: 'LoadingSettings', + title: i18n._(t`Loading`), + type: 'column', + children: [ + { + name: 'ResourcesPreloading', + getLabel: () => i18n._(t`Resources preloading`), + valueType: 'string', + getChoices: () => resourcesPreloadingFieldChoices, + getValue: ({ object }: { object: gdObject }): string => + object.getResourcesPreloading(), + setValue: ({ object }: { object: gdObject }, newValue: string) => + object.setResourcesPreloading(newValue), + defaultValue: 'with-scene', + visibility: 'advanced', + }, + ], + }; +}; + type Props = {| project: gdProject, resourceManagementProps: ResourceManagementProps, @@ -508,7 +544,7 @@ export const CompactObjectPropertiesEditor = ({ // schemaRecomputeTrigger allows to invalidate the schema when required. } const properties = objectConfigurationAsGd.getProperties(); - return propertiesMapToSchema({ + const schema = propertiesMapToSchema({ properties, defaultValueProperties: customObjectEventsBasedObject ? customObjectEventsBasedObject.getPropertyDescriptors() @@ -526,13 +562,20 @@ export const CompactObjectPropertiesEditor = ({ object, visibility: 'All', }); + + if (layout && layout.getObjects().hasObjectNamed(object.getName())) { + schema.push(getResourcesPreloadingField({ i18n })); + } + return schema; }, [ schemaRecomputeTrigger, objectConfigurationAsGd, - object, customObjectEventsBasedObject, + object, + layout, onObjectsModified, + i18n, ] );