Make games launch faster by loading resources in the background (#5572)

* Only the first scene and global objects resources (images, sounds, 3D models etc...) will be downloaded during launch of the game. This usually allows for a very fast loading time.
* Other scenes resources will continue to load in the background. It has no impact on the game performance as this is done on other threads by the browser or the engine running the game.
* Scenes are loaded in the order they are listed in the project manager.
* You can also use actions and expressions to prioritize a scene (if it's known that a level will be needed soon for example) or read the current loading progress. This allows to create lightweight scenes that can act as custom loading screens. Otherwise, the launch loading screen will be shown if a scene is still loading when launched.
* Read more about this on https://wiki.gdevelop.io/gdevelop5/all-features/resources-loading/.
This commit is contained in:
D8H
2023-11-22 22:51:24 +01:00
committed by GitHub
parent b7da4361c3
commit 1f852648ef
56 changed files with 3325 additions and 1303 deletions

View File

@@ -5,6 +5,7 @@
*/
#include "AllBuiltinExtensions.h"
#include "GDCore/Tools/Localization.h"
#include "GDCore/Extensions/Metadata/MultipleInstructionMetadata.h"
using namespace std;
namespace gd {
@@ -57,7 +58,7 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSceneExtension(
extension
.AddCondition("DoesSceneExist",
_("Does scene exist"),
_("Check if scene exists."),
_("Check if a scene exists."),
_("Scene _PARAM1_ exists"),
"",
"res/actions/texte.png",
@@ -163,6 +164,45 @@ void GD_CORE_API BuiltinExtensionsImplementer::ImplementsSceneExtension(
"res/actions/window.png")
.SetHelpPath("/interface/scene-editor/events")
.AddCodeOnlyParameter("currentScene", "");
extension
.AddAction("PrioritizeLoadingOfScene",
_("Preload scene"),
_("Preload a scene resources as soon as possible in background."),
_("Preload scene _PARAM1_ in background"),
"",
"res/actions/replaceScene24.png",
"res/actions/replaceScene.png")
.SetHelpPath("/all-features/resources-loading")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("sceneName", _("Name of the new scene"))
.MarkAsAdvanced();
extension.AddExpressionAndCondition("number",
"SceneLoadingProgress",
_("Scene loading progress"),
_("The progress of resources loading in background for a scene (between 0 and 1)."),
_("_PARAM0_ loading progress"),
_(""),
"res/actions/replaceScene24.png")
.SetHelpPath("/all-features/resources-loading")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("sceneName", _("Scene name"))
.UseStandardParameters("number", ParameterOptions::MakeNewOptions())
.MarkAsAdvanced();
extension
.AddCondition("AreSceneAssetsLoaded",
_("Scene preloaded"),
_("Check if scene resources have finished to load in background."),
_("Scene _PARAM1_ was preloaded in background"),
"",
"res/actions/replaceScene24.png",
"res/actions/replaceScene.png")
.SetHelpPath("/all-features/resources-loading")
.AddCodeOnlyParameter("currentScene", "")
.AddParameter("sceneName", _("Scene name"))
.MarkAsAdvanced();
}
} // namespace gd

View File

@@ -191,6 +191,16 @@ class GD_CORE_API MultipleInstructionMetadata : public AbstractFunctionMetadata
return *this;
}
/**
* \see gd::InstructionMetadata::SetHelpPath
*/
MultipleInstructionMetadata &SetHelpPath(const gd::String &path) {
if (expression) expression->SetHelpPath(path);
if (condition) condition->SetHelpPath(path);
if (action) action->SetHelpPath(path);
return *this;
}
/**
* \see gd::InstructionMetadata::MarkAsSimple
*/

View File

@@ -4,7 +4,6 @@
* reserved. This project is released under the MIT License.
*/
#if defined(GD_IDE_ONLY)
#include "DependenciesAnalyzer.h"
#include <algorithm>
#include "GDCore/Events/Builtin/LinkEvent.h"
@@ -29,9 +28,9 @@ DependenciesAnalyzer::DependenciesAnalyzer(const gd::Project& project_,
bool DependenciesAnalyzer::Analyze() {
if (layout)
return Analyze(layout->GetEvents(), true);
return Analyze(layout->GetEvents());
else if (externalEvents)
return Analyze(externalEvents->GetEvents(), true);
return Analyze(externalEvents->GetEvents());
std::cout << "ERROR: DependenciesAnalyzer called without any layout or "
"external events.";
@@ -40,63 +39,38 @@ bool DependenciesAnalyzer::Analyze() {
DependenciesAnalyzer::~DependenciesAnalyzer() {}
bool DependenciesAnalyzer::Analyze(const gd::EventsList& events, bool isOnTopLevel) {
bool DependenciesAnalyzer::Analyze(const gd::EventsList& events) {
for (unsigned int i = 0; i < events.size(); ++i) {
const gd::LinkEvent* linkEvent = dynamic_cast<const gd::LinkEvent*>(&events[i]);
if (linkEvent) {
DependenciesAnalyzer analyzer(*this);
gd::String linked = linkEvent->GetTarget();
if (project.HasExternalEventsNamed(linked)) {
if (std::find(parentExternalEvents.begin(),
parentExternalEvents.end(),
linked) != parentExternalEvents.end())
return false; // Circular dependency!
externalEventsDependencies.insert(
linked); // There is a direct dependency
if (!isOnTopLevel) notTopLevelExternalEventsDependencies.insert(linked);
analyzer.AddParentExternalEvents(linked);
if (!analyzer.Analyze(project.GetExternalEvents(linked).GetEvents(),
isOnTopLevel))
linked) != parentExternalEvents.end()) {
// Circular dependency!
return false;
}
bool wasDependencyJustAdded = externalEventsDependencies.insert(linked).second;
if (wasDependencyJustAdded) {
parentExternalEvents.push_back(linked);
if (!Analyze(project.GetExternalEvents(linked).GetEvents()))
return false;
parentExternalEvents.pop_back();
}
} else if (project.HasLayoutNamed(linked)) {
if (std::find(parentScenes.begin(), parentScenes.end(), linked) !=
parentScenes.end())
return false; // Circular dependency!
scenesDependencies.insert(linked); // There is a direct dependency
if (!isOnTopLevel) notTopLevelScenesDependencies.insert(linked);
analyzer.AddParentScene(linked);
if (!analyzer.Analyze(project.GetLayout(linked).GetEvents(),
isOnTopLevel))
parentScenes.end()) {
// Circular dependency!
return false;
}
// Update with indirect dependencies.
scenesDependencies.insert(analyzer.GetScenesDependencies().begin(),
analyzer.GetScenesDependencies().end());
externalEventsDependencies.insert(
analyzer.GetExternalEventsDependencies().begin(),
analyzer.GetExternalEventsDependencies().end());
sourceFilesDependencies.insert(
analyzer.GetSourceFilesDependencies().begin(),
analyzer.GetSourceFilesDependencies().end());
notTopLevelScenesDependencies.insert(
analyzer.GetNotTopLevelScenesDependencies().begin(),
analyzer.GetNotTopLevelScenesDependencies().end());
notTopLevelExternalEventsDependencies.insert(
analyzer.GetNotTopLevelExternalEventsDependencies().begin(),
analyzer.GetNotTopLevelExternalEventsDependencies().end());
if (!isOnTopLevel) {
notTopLevelScenesDependencies.insert(
analyzer.GetScenesDependencies().begin(),
analyzer.GetScenesDependencies().end());
notTopLevelExternalEventsDependencies.insert(
analyzer.GetExternalEventsDependencies().begin(),
analyzer.GetExternalEventsDependencies().end());
}
bool wasDependencyJustAdded = scenesDependencies.insert(linked).second;
if (wasDependencyJustAdded) {
parentScenes.push_back(linked);
if (!Analyze(project.GetLayout(linked).GetEvents()))
return false;
parentScenes.pop_back();
}
}
}
@@ -112,45 +86,9 @@ bool DependenciesAnalyzer::Analyze(const gd::EventsList& events, bool isOnTopLev
// Analyze sub events dependencies
if (events[i].CanHaveSubEvents()) {
if (!Analyze(events[i].GetSubEvents(), false)) return false;
if (!Analyze(events[i].GetSubEvents())) return false;
}
}
return true;
}
gd::String DependenciesAnalyzer::ExternalEventsCanBeCompiledForAScene() {
if (!externalEvents) {
std::cout << "ERROR: ExternalEventsCanBeCompiledForAScene called without "
"external events set!"
<< std::endl;
return "";
}
gd::String sceneName;
for (unsigned int i = 0; i < project.GetLayoutsCount(); ++i) {
// For each layout, compute the dependencies and the dependencies which are
// not coming from a top level event.
DependenciesAnalyzer analyzer(project, project.GetLayout(i));
if (!analyzer.Analyze()) continue; // Analyze failed -> Cyclic dependencies
const std::set<gd::String>& dependencies =
analyzer.GetExternalEventsDependencies();
const std::set<gd::String>& notTopLevelDependencies =
analyzer.GetNotTopLevelExternalEventsDependencies();
// Check if the external events is a dependency, and that is is only present
// as a link on the top level.
if (dependencies.find(externalEvents->GetName()) != dependencies.end() &&
notTopLevelDependencies.find(externalEvents->GetName()) ==
notTopLevelDependencies.end()) {
if (!sceneName.empty())
return ""; // External events can be compiled only if one scene is
// including them.
else
sceneName = project.GetLayout(i).GetName();
}
}
return sceneName; // External events can be compiled and used for the scene.
}
#endif

View File

@@ -39,11 +39,6 @@ class GD_CORE_API DependenciesAnalyzer {
/**
* \brief Constructor for analyzing the dependencies of external events.
*
* You can also call then
* DependenciesAnalyzer::ExternalEventsCanBeCompiledForAScene to check if the
* external events can be compiled separately and called by a scene. \see
* DependenciesAnalyzer::ExternalEventsCanBeCompiledForAScene
*/
DependenciesAnalyzer(const gd::Project& project_,
const gd::ExternalEvents& externalEvents);
@@ -60,18 +55,6 @@ class GD_CORE_API DependenciesAnalyzer {
*/
bool Analyze();
/**
* Check if the external events (passed in the constructor) can be compiled
* and called by a single scene:<br> This is possible when the link calling
* the external events does not have any parent event and when this situation
* occurs only in a single scene and not in another.
*
* \return The name of the scene which is able to call the compiled external
* events. If empty, no scene is able to call them. (So external events have
* to be included directly by links).
*/
gd::String ExternalEventsCanBeCompiledForAScene();
/**
* \brief Return the scenes being dependencies of the scene or external events
* passed in the constructor.
@@ -96,25 +79,6 @@ class GD_CORE_API DependenciesAnalyzer {
return sourceFilesDependencies;
};
/**
* \brief Return the scenes being dependencies of the scene or external events
* passed in the constructor, but being not top level dependencies: The links
* including them are not a top level events (i.e: They have a parent event).
*/
const std::set<gd::String>& GetNotTopLevelScenesDependencies() const {
return notTopLevelScenesDependencies;
};
/**
* \brief Return the external events being dependencies of the scene or
* external events passed in the constructor, but being not top level
* dependencies: The links including them are not a top level events (i.e:
* They have a parent event).
*/
const std::set<gd::String>& GetNotTopLevelExternalEventsDependencies() const {
return notTopLevelExternalEventsDependencies;
};
private:
/**
* \brief Analyze the dependencies of the events.
@@ -124,32 +88,11 @@ class GD_CORE_API DependenciesAnalyzer {
* (they have no parents). \return false if a circular dependency exists, true
* otherwise.
*/
bool Analyze(const gd::EventsList& events, bool isOnTopLevel);
void AddParentScene(gd::String parentScene) {
parentScenes.push_back(parentScene);
};
void AddParentExternalEvents(gd::String parentExternalEvents_) {
parentExternalEvents.push_back(parentExternalEvents_);
};
/**
* Return true if all links pointing to external events called \a
* externalEventsName are only at the top level of \a events. The function
* return false as soon as it discover a link to external events which is not
* at the top level ( i.e: It has a parent event ).
*
* \warning The function assumes that there are not cyclic dependencies.
*/
bool CheckIfExternalEventsIsLinkedOnlyAtTopLevel(
const gd::String& externalEventsName,
std::vector<std::shared_ptr<gd::BaseEvent> >& events);
bool Analyze(const gd::EventsList& events);
std::set<gd::String> scenesDependencies;
std::set<gd::String> externalEventsDependencies;
std::set<gd::String> sourceFilesDependencies;
std::set<gd::String> notTopLevelScenesDependencies;
std::set<gd::String> notTopLevelExternalEventsDependencies;
std::vector<gd::String>
parentScenes; ///< Used to check for circular dependencies.
std::vector<gd::String>

View File

@@ -226,7 +226,7 @@ void EventsIdentifiersFinder::FindArgumentsInEventsAndDependencies(
eventWorker.Launch(layout.GetEvents(),
gd::ProjectScopedContainers::MakeNewProjectScopedContainersForProjectAndLayout(project, layout));
DependenciesAnalyzer dependenciesAnalyzer = DependenciesAnalyzer(project, layout);
DependenciesAnalyzer dependenciesAnalyzer(project, layout);
dependenciesAnalyzer.Analyze();
for (const gd::String& externalEventName : dependenciesAnalyzer.GetExternalEventsDependencies()) {
const gd::ExternalEvents& externalEvents = project.GetExternalEvents(externalEventName);

View File

@@ -258,7 +258,7 @@ void EventsVariablesFinder::FindArgumentsInEventsAndDependencies(
eventWorker.Launch(layout.GetEvents(),
gd::ProjectScopedContainers::MakeNewProjectScopedContainersForProjectAndLayout(project, layout));
DependenciesAnalyzer dependenciesAnalyzer = DependenciesAnalyzer(project, layout);
DependenciesAnalyzer dependenciesAnalyzer(project, layout);
dependenciesAnalyzer.Analyze();
for (const gd::String& externalEventName : dependenciesAnalyzer.GetExternalEventsDependencies()) {
const gd::ExternalEvents& externalEvents = project.GetExternalEvents(externalEventName);

View File

@@ -262,13 +262,6 @@ bool ResourceWorkerInEventsWorker::DoVisitInstruction(gd::Instruction& instructi
return false;
};
void LaunchResourceWorkerOnEvents(const gd::Project& project,
gd::EventsList& events,
gd::ArbitraryResourceWorker& worker) {
gd::ResourceWorkerInEventsWorker eventsWorker(project, worker);
eventsWorker.Launch(events);
}
gd::ResourceWorkerInEventsWorker
GetResourceWorkerOnEvents(const gd::Project &project,
gd::ArbitraryResourceWorker &worker) {

View File

@@ -37,7 +37,7 @@ namespace gd {
* \see ResourcesMergingHelper
* \see gd::ResourcesInUseHelper
*
* \see gd::LaunchResourceWorkerOnEvents
* \see gd::GetResourceWorkerOnEvents
*
* \ingroup IDE
*/

View File

@@ -0,0 +1,35 @@
/*
* GDevelop JS Platform
* Copyright 2008-2023 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
#include "SceneResourcesFinder.h"
#include "GDCore/IDE/ResourceExposer.h"
#include "GDCore/Project/Layout.h"
#include "GDCore/Project/Project.h"
#include "GDCore/Serialization/SerializerElement.h"
namespace gd {
std::set<gd::String> SceneResourcesFinder::FindProjectResources(gd::Project &project) {
gd::SceneResourcesFinder resourceWorker;
gd::ResourceExposer::ExposeProjectResources(project, resourceWorker);
return resourceWorker.resourceNames;
}
std::set<gd::String> SceneResourcesFinder::FindSceneResources(gd::Project &project,
gd::Layout &layout) {
gd::SceneResourcesFinder resourceWorker;
gd::ResourceExposer::ExposeLayoutResources(project, layout, resourceWorker);
return resourceWorker.resourceNames;
}
void SceneResourcesFinder::AddUsedResource(gd::String &resourceName) {
if (resourceName.empty()) {
return;
}
resourceNames.insert(resourceName);
}
} // namespace gd

View File

@@ -0,0 +1,86 @@
/*
* GDevelop JS Platform
* Copyright 2008-2023 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
#pragma once
#include "GDCore/IDE/Project/ArbitraryResourceWorker.h"
#include "GDCore/String.h"
#include <set>
namespace gd {
class Project;
class Layout;
class SerializerElement;
} // namespace gd
namespace gd {
/**
* \brief Find resource usages in several parts of the project.
*
* \ingroup IDE
*/
class SceneResourcesFinder : private gd::ArbitraryResourceWorker {
public:
/**
* @brief Find resource usages in a given scenes.
*
* It doesn't include resources used globally.
*/
static std::set<gd::String> FindSceneResources(gd::Project &project,
gd::Layout &layout);
/**
* @brief Find resource that are used globally in the project.
*
* It doesn't include resources used in scenes.
*/
static std::set<gd::String> FindProjectResources(gd::Project &project);
virtual ~SceneResourcesFinder(){};
private:
SceneResourcesFinder() : gd::ArbitraryResourceWorker(){};
void AddUsedResource(gd::String &resourceName);
void ExposeFile(gd::String &resourceFileName) override{
// Don't do anything: we're renaming resources, not the files they are
// pointing to.
};
void ExposeImage(gd::String &imageResourceName) override {
AddUsedResource(imageResourceName);
};
void ExposeAudio(gd::String &audioResourceName) override {
AddUsedResource(audioResourceName);
};
void ExposeFont(gd::String &fontResourceName) override {
AddUsedResource(fontResourceName);
};
void ExposeJson(gd::String &jsonResourceName) override {
AddUsedResource(jsonResourceName);
};
void ExposeTilemap(gd::String &tilemapResourceName) override {
AddUsedResource(tilemapResourceName);
};
void ExposeTileset(gd::String &tilesetResourceName) override {
AddUsedResource(tilesetResourceName);
};
void ExposeVideo(gd::String &videoResourceName) override {
AddUsedResource(videoResourceName);
};
void ExposeBitmapFont(gd::String &bitmapFontName) override {
AddUsedResource(bitmapFontName);
};
void ExposeModel3D(gd::String &resourceName) override {
AddUsedResource(resourceName);
};
std::set<gd::String> resourceNames;
};
} // namespace gd

View File

@@ -20,6 +20,7 @@
#include "GDCore/Project/Project.h"
#include "GDCore/Project/ProjectScopedContainers.h"
#include "GDCore/String.h"
#include "GDCore/IDE/DependenciesAnalyzer.h"
namespace gd {
@@ -33,27 +34,8 @@ void ProjectBrowserHelper::ExposeProjectEvents(
// Add events based extensions
for (std::size_t e = 0; e < project.GetEventsFunctionsExtensionsCount();
e++) {
// Add (free) events functions
auto &eventsFunctionsExtension = project.GetEventsFunctionsExtension(e);
for (auto &&eventsFunction : eventsFunctionsExtension.GetInternalVector()) {
worker.Launch(eventsFunction->GetEvents());
}
// Add (behavior) events functions
for (auto &&eventsBasedBehavior :
eventsFunctionsExtension.GetEventsBasedBehaviors()
.GetInternalVector()) {
ExposeEventsBasedBehaviorEvents(project, *eventsBasedBehavior, worker);
}
// Add (object) events functions
for (auto &&eventsBasedObject :
eventsFunctionsExtension.GetEventsBasedObjects().GetInternalVector()) {
auto &objectEventsFunctions = eventsBasedObject->GetEventsFunctions();
for (auto &&eventsFunction : objectEventsFunctions.GetInternalVector()) {
worker.Launch(eventsFunction->GetEvents());
}
}
ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(project, eventsFunctionsExtension, worker);
}
}
@@ -69,7 +51,7 @@ void ProjectBrowserHelper::ExposeProjectEventsWithoutExtensions(
}
}
void ProjectBrowserHelper::ExposeLayoutEvents(
void ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(
gd::Project &project, gd::Layout &layout,
gd::ArbitraryEventsWorker &worker) {
@@ -85,7 +67,7 @@ void ProjectBrowserHelper::ExposeLayoutEvents(
}
}
void ProjectBrowserHelper::ExposeLayoutEvents(
void ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(
gd::Project &project, gd::Layout &layout,
gd::ArbitraryEventsWorkerWithContext &worker) {
auto projectScopedContainers =
@@ -103,6 +85,32 @@ void ProjectBrowserHelper::ExposeLayoutEvents(
}
}
void ProjectBrowserHelper::ExposeLayoutEventsAndDependencies(
gd::Project &project, gd::Layout &layout,
gd::ArbitraryEventsWorker &worker) {
// Add layouts events
worker.Launch(layout.GetEvents());
DependenciesAnalyzer dependenciesAnalyzer(project, layout);
bool hasCircularDependencies = !dependenciesAnalyzer.Analyze();
if (hasCircularDependencies) {
// The analyzer stops when it finds circular dependencies so the dependencies are not complete.
// TODO Should the analyzer still continue to avoid side effect on thing that would not be code generation related?
// Maybe a boolean parameter should be added?
return;
}
for (const gd::String& externalEventName : dependenciesAnalyzer.GetExternalEventsDependencies()) {
gd::ExternalEvents& externalEvents = project.GetExternalEvents(externalEventName);
worker.Launch(externalEvents.GetEvents());
}
for (const gd::String& sceneName : dependenciesAnalyzer.GetScenesDependencies()) {
gd::Layout& dependencyLayout = project.GetLayout(sceneName);
worker.Launch(dependencyLayout.GetEvents());
}
}
void ProjectBrowserHelper::ExposeProjectEvents(
gd::Project &project, gd::ArbitraryEventsWorkerWithContext &worker) {
// See also gd::Project::ExposeResources for a method that traverse the whole
@@ -130,8 +138,43 @@ void ProjectBrowserHelper::ExposeProjectEvents(
// Add events based extensions
for (std::size_t e = 0; e < project.GetEventsFunctionsExtensionsCount();
e++) {
// Add (free) events functions
auto &eventsFunctionsExtension = project.GetEventsFunctionsExtension(e);
ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(project, eventsFunctionsExtension, worker);
}
}
void ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(
gd::Project &project, const gd::EventsFunctionsExtension &eventsFunctionsExtension,
gd::ArbitraryEventsWorker &worker) {
// Add (free) events functions
for (auto &&eventsFunction : eventsFunctionsExtension.GetInternalVector()) {
gd::ObjectsContainer globalObjectsAndGroups;
gd::ObjectsContainer objectsAndGroups;
gd::EventsFunctionTools::FreeEventsFunctionToObjectsContainer(
project, eventsFunctionsExtension, *eventsFunction,
globalObjectsAndGroups, objectsAndGroups);
worker.Launch(eventsFunction->GetEvents());
}
// Add (behavior) events functions
for (auto &&eventsBasedBehavior :
eventsFunctionsExtension.GetEventsBasedBehaviors()
.GetInternalVector()) {
ExposeEventsBasedBehaviorEvents(project, *eventsBasedBehavior, worker);
}
// Add (object) events functions
for (auto &&eventsBasedObject :
eventsFunctionsExtension.GetEventsBasedObjects().GetInternalVector()) {
ExposeEventsBasedObjectEvents(project, *eventsBasedObject, worker);
}
}
void ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(
gd::Project &project, const gd::EventsFunctionsExtension &eventsFunctionsExtension,
gd::ArbitraryEventsWorkerWithContext &worker) {
// Add (free) events functions
for (auto &&eventsFunction : eventsFunctionsExtension.GetInternalVector()) {
gd::ObjectsContainer globalObjectsAndGroups;
gd::ObjectsContainer objectsAndGroups;
@@ -157,7 +200,6 @@ void ProjectBrowserHelper::ExposeProjectEvents(
eventsFunctionsExtension.GetEventsBasedObjects().GetInternalVector()) {
ExposeEventsBasedObjectEvents(project, *eventsBasedObject, worker);
}
}
}
void ProjectBrowserHelper::ExposeEventsBasedBehaviorEvents(
@@ -189,6 +231,21 @@ void ProjectBrowserHelper::ExposeEventsBasedBehaviorEvents(
}
}
void ProjectBrowserHelper::ExposeEventsBasedObjectEvents(
gd::Project &project, const gd::EventsBasedObject &eventsBasedObject,
gd::ArbitraryEventsWorker &worker) {
auto &objectEventsFunctions = eventsBasedObject.GetEventsFunctions();
for (auto &&eventsFunction : objectEventsFunctions.GetInternalVector()) {
gd::ObjectsContainer globalObjectsAndGroups;
gd::ObjectsContainer objectsAndGroups;
gd::EventsFunctionTools::ObjectEventsFunctionToObjectsContainer(
project, eventsBasedObject, *eventsFunction, globalObjectsAndGroups,
objectsAndGroups);
worker.Launch(eventsFunction->GetEvents());
}
}
void ProjectBrowserHelper::ExposeEventsBasedObjectEvents(
gd::Project &project, const gd::EventsBasedObject &eventsBasedObject,
gd::ArbitraryEventsWorkerWithContext &worker) {
@@ -216,7 +273,7 @@ void ProjectBrowserHelper::ExposeProjectObjects(
// Layout objects
for (size_t i = 0; i < project.GetLayoutsCount(); i++) {
worker.Launch(project.GetLayout(i));
gd::ProjectBrowserHelper::ExposeLayoutObjects(project.GetLayout(i), worker);
}
// Event based objects children
@@ -232,6 +289,14 @@ void ProjectBrowserHelper::ExposeProjectObjects(
}
};
void ProjectBrowserHelper::ExposeLayoutObjects(gd::Layout &layout,
gd::ArbitraryObjectsWorker &worker) {
// In the future, layouts may have children object containers.
// Layout objects
worker.Launch(layout);
}
void ProjectBrowserHelper::ExposeProjectFunctions(
gd::Project &project, gd::ArbitraryEventsFunctionsWorker &worker) {

View File

@@ -60,18 +60,52 @@ public:
* \brief Call the specified worker on all events of a layout and
* its external events.
*/
static void ExposeLayoutEvents(gd::Project &project, gd::Layout &layout,
static void ExposeLayoutEventsAndExternalEvents(gd::Project &project, gd::Layout &layout,
gd::ArbitraryEventsWorker &worker);
/**
* \brief Call the specified worker on all events of a layout and
* its external events.
*/
static void ExposeLayoutEvents(gd::Project &project, gd::Layout &layout,
static void ExposeLayoutEventsAndExternalEvents(gd::Project &project, gd::Layout &layout,
gd::ArbitraryEventsWorkerWithContext &worker);
/**
* \brief Call the specified worker on all events of a layout and
* its dependencies according to EventLink (external events or other layout
* events).
*/
static void
ExposeLayoutEventsAndDependencies(gd::Project &project, gd::Layout &layout,
gd::ArbitraryEventsWorker &worker);
/**
* \brief Call the specified worker on all events of the event-based
* behavior
* extension.
*
* This should be the preferred way to traverse all the events of an events
* based extension.
*/
static void ExposeEventsFunctionsExtensionEvents(
gd::Project &project,
const gd::EventsFunctionsExtension &eventsFunctionsExtension,
gd::ArbitraryEventsWorker &worker);
/**
* \brief Call the specified worker on all events of the event-based
* extension.
*
* This should be the preferred way to traverse all the events of an events
* based extension.
*/
static void ExposeEventsFunctionsExtensionEvents(
gd::Project &project,
const gd::EventsFunctionsExtension &eventsFunctionsExtension,
gd::ArbitraryEventsWorkerWithContext &worker);
/**
* \brief Call the specified worker on all events of the event-based
* behavior.
*
* This should be the preferred way to traverse all the events of an events
* based behavior.
@@ -93,10 +127,22 @@ public:
/**
* \brief Call the specified worker on all events of the event-based
* behavior.
* object.
*
* This should be the preferred way to traverse all the events of an
* event-based behavior.
* event-based object.
*/
static void
ExposeEventsBasedObjectEvents(gd::Project &project,
const gd::EventsBasedObject &eventsBasedObject,
gd::ArbitraryEventsWorker &worker);
/**
* \brief Call the specified worker on all events of the event-based
* object.
*
* This should be the preferred way to traverse all the events of an
* event-based object.
*/
static void
ExposeEventsBasedObjectEvents(gd::Project &project,
@@ -112,6 +158,14 @@ public:
static void ExposeProjectObjects(gd::Project &project,
gd::ArbitraryObjectsWorker &worker);
/**
* \brief Call the specified worker on all ObjectContainers of the layout.
*
* This should be the preferred way to traverse all the objects of a layout.
*/
static void ExposeLayoutObjects(gd::Layout &layout,
gd::ArbitraryObjectsWorker &worker);
/**
* \brief Call the specified worker on all FunctionsContainers of the project
* (global, layouts...)

View File

@@ -24,6 +24,7 @@
#include "GDCore/Extensions/Platform.h"
#include "GDCore/Extensions/Metadata/MetadataProvider.h"
#include "GDCore/Extensions/Metadata/EffectMetadata.h"
#include "GDCore/IDE/Events/UsedExtensionsFinder.h"
namespace gd {
@@ -36,6 +37,7 @@ void ResourceExposer::ExposeWholeProjectResources(gd::Project& project, gd::Arbi
// Expose any project resources as files.
worker.ExposeResources(resourcesManager);
project.GetPlatformSpecificAssets().ExposeResources(worker);
// Expose event resources
@@ -73,6 +75,49 @@ void ResourceExposer::ExposeWholeProjectResources(gd::Project& project, gd::Arbi
worker.ExposeImage(loadingScreen.GetBackgroundImageResourceName());
}
void ResourceExposer::ExposeProjectResources(gd::Project& project, gd::ArbitraryResourceWorker& worker) {
// Expose global objects configuration resources
auto objectWorker = gd::GetResourceWorkerOnObjects(project, worker);
objectWorker.Launch(project);
}
void ResourceExposer::ExposeLayoutResources(
gd::Project &project, gd::Layout &layout,
gd::ArbitraryResourceWorker &worker) {
// Expose object configuration resources
auto objectWorker = gd::GetResourceWorkerOnObjects(project, worker);
gd::ProjectBrowserHelper::ExposeLayoutObjects(layout, objectWorker);
// Expose layer effect resources
for (std::size_t layerIndex = 0; layerIndex < layout.GetLayersCount();
layerIndex++) {
auto &layer = layout.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);
}
}
// Expose event resources
auto eventWorker = gd::GetResourceWorkerOnEvents(project, worker);
gd::ProjectBrowserHelper::ExposeLayoutEventsAndDependencies(project, layout,
eventWorker);
// Exposed extension event resources
// Note that using resources in extensions is very unlikely and probably not
// worth the effort of something smart.
for (std::size_t e = 0; e < project.GetEventsFunctionsExtensionsCount();
e++) {
auto &eventsFunctionsExtension = project.GetEventsFunctionsExtension(e);
gd::ProjectBrowserHelper::ExposeEventsFunctionsExtensionEvents(project, eventsFunctionsExtension, eventWorker);
}
}
void ResourceExposer::ExposeEffectResources(
gd::Platform &platform, gd::Effect &effect,
gd::ArbitraryResourceWorker &worker) {
@@ -88,11 +133,13 @@ void ResourceExposer::ExposeEffectResources(
auto &resourceType = propertyDescriptor.GetExtraInfo()[0];
const gd::String &resourceName = effect.GetStringParameter(propertyName);
gd::String potentiallyUpdatedResourceName = resourceName;
worker.ExposeResourceWithType(resourceType,
potentiallyUpdatedResourceName);
if (potentiallyUpdatedResourceName != resourceName) {
effect.SetStringParameter(propertyName, potentiallyUpdatedResourceName);
if (!resourceName.empty()) {
gd::String potentiallyUpdatedResourceName = resourceName;
worker.ExposeResourceWithType(resourceType,
potentiallyUpdatedResourceName);
if (potentiallyUpdatedResourceName != resourceName) {
effect.SetStringParameter(propertyName, potentiallyUpdatedResourceName);
}
}
}
}

View File

@@ -10,6 +10,7 @@ class Platform;
class Project;
class ArbitraryResourceWorker;
class Effect;
class Layout;
} // namespace gd
namespace gd {
@@ -31,6 +32,25 @@ public:
static void ExposeWholeProjectResources(gd::Project &project,
gd::ArbitraryResourceWorker &worker);
/**
* @brief Expose only the resources used globally on a project.
*
* It doesn't include resources used in layouts.
*/
static void ExposeProjectResources(gd::Project &project,
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);
/**
* @brief Expose the resources used in a given effect.
*/
static void ExposeEffectResources(gd::Platform &platform, gd::Effect &effect,
gd::ArbitraryResourceWorker &worker);
};

View File

@@ -1538,7 +1538,7 @@ void WholeProjectRefactorer::RenameLayer(gd::Project &project,
return;
gd::ProjectElementRenamer projectElementRenamer(project.GetCurrentPlatform(),
"layer", oldName, newName);
gd::ProjectBrowserHelper::ExposeLayoutEvents(project, layout,
gd::ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(project, layout,
projectElementRenamer);
}
@@ -1552,7 +1552,7 @@ void WholeProjectRefactorer::RenameLayerEffect(gd::Project &project,
gd::ProjectElementRenamer projectElementRenamer(
project.GetCurrentPlatform(), "layerEffectName", oldName, newName);
projectElementRenamer.SetLayerConstraint(layer.GetName());
gd::ProjectBrowserHelper::ExposeLayoutEvents(project, layout,
gd::ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(project, layout,
projectElementRenamer);
}
@@ -1566,7 +1566,7 @@ void WholeProjectRefactorer::RenameObjectAnimation(gd::Project &project,
gd::ProjectElementRenamer projectElementRenamer(
project.GetCurrentPlatform(), "objectAnimationName", oldName, newName);
projectElementRenamer.SetObjectConstraint(object.GetName());
gd::ProjectBrowserHelper::ExposeLayoutEvents(project, layout,
gd::ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(project, layout,
projectElementRenamer);
}
@@ -1580,7 +1580,7 @@ void WholeProjectRefactorer::RenameObjectPoint(gd::Project &project,
gd::ProjectElementRenamer projectElementRenamer(
project.GetCurrentPlatform(), "objectPointName", oldName, newName);
projectElementRenamer.SetObjectConstraint(object.GetName());
gd::ProjectBrowserHelper::ExposeLayoutEvents(project, layout,
gd::ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(project, layout,
projectElementRenamer);
}
@@ -1594,7 +1594,7 @@ void WholeProjectRefactorer::RenameObjectEffect(gd::Project &project,
gd::ProjectElementRenamer projectElementRenamer(
project.GetCurrentPlatform(), "objectEffectName", oldName, newName);
projectElementRenamer.SetObjectConstraint(object.GetName());
gd::ProjectBrowserHelper::ExposeLayoutEvents(project, layout,
gd::ProjectBrowserHelper::ExposeLayoutEventsAndExternalEvents(project, layout,
projectElementRenamer);
}

View File

@@ -534,19 +534,7 @@ void ResourcesManager::SerializeTo(SerializerElement& element) const {
if (resources[i] == std::shared_ptr<Resource>()) break;
SerializerElement& resourceElement = resourcesElement.AddChild("resource");
resourceElement.SetAttribute("kind", resources[i]->GetKind());
resourceElement.SetAttribute("name", resources[i]->GetName());
resourceElement.SetAttribute("metadata", resources[i]->GetMetadata());
const gd::String& originName = resources[i]->GetOriginName();
const gd::String& originIdentifier = resources[i]->GetOriginIdentifier();
if (!originName.empty() || !originIdentifier.empty()) {
resourceElement.AddChild("origin")
.SetAttribute("name", originName)
.SetAttribute("identifier", originIdentifier);
}
resources[i]->SerializeTo(resourceElement);
gd::ResourcesManager::SerializeResourceTo(*resources[i], resourceElement);
}
SerializerElement& resourcesFoldersElement =
@@ -556,6 +544,22 @@ void ResourcesManager::SerializeTo(SerializerElement& element) const {
folders[i].SerializeTo(resourcesFoldersElement.AddChild("folder"));
}
void ResourcesManager::SerializeResourceTo(gd::Resource &resource,
SerializerElement &resourceElement) {
resourceElement.SetAttribute("kind", resource.GetKind());
resourceElement.SetAttribute("name", resource.GetName());
resourceElement.SetAttribute("metadata", resource.GetMetadata());
const gd::String &originName = resource.GetOriginName();
const gd::String &originIdentifier = resource.GetOriginIdentifier();
if (!originName.empty() || !originIdentifier.empty()) {
resourceElement.AddChild("origin")
.SetAttribute("name", originName)
.SetAttribute("identifier", originIdentifier);
}
resource.SerializeTo(resourceElement);
}
void ImageResource::SetFile(const gd::String& newFile) {
file = NormalizePathSeparator(newFile);
}

View File

@@ -662,6 +662,11 @@ class GD_CORE_API ResourcesManager {
*/
void SerializeTo(SerializerElement& element) const;
/**
* \brief Serialize one resource.
*/
static void SerializeResourceTo(gd::Resource& resource, SerializerElement& resourceElement);
/**
* \brief Unserialize the object.
*/

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ describe('gdjs.AnchorRuntimeBehavior', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
function createObject(behaviorProperties) {

View File

@@ -27,6 +27,7 @@ describe('gdjs.DraggableRuntimeBehavior', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
var object = new gdjs.TestRuntimeObject(runtimeScene, {

View File

@@ -33,6 +33,7 @@ describe('gdjs.LinksManager', function () {
name: 'Scene1',
stopSoundsOnStartup: false,
title: '',
usedResources: [],
});
const manager = gdjs.LinksManager.getManager(runtimeScene);

View File

@@ -34,6 +34,7 @@ describe('gdjs.PathfindingRuntimeBehavior', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
setFramePerSecond(runtimeScene, framePerSecond);
return runtimeScene;

View File

@@ -37,6 +37,7 @@ describe('gdjs.PathfindingRuntimeBehavior', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
setFramePerSecond(runtimeScene, framePerSecond);
return runtimeScene;

View File

@@ -39,6 +39,7 @@ describe('gdjs.PathfindingRuntimeBehavior', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
runtimeScene._timeManager.getElapsedTime = function () {
return (1 / 60) * 1000;

View File

@@ -48,6 +48,7 @@ describe('gdjs.ShapePainterRuntimeObject (using a PixiJS RuntimeGame with assets
objects: [],
instances: [],
variables: [],
usedResources: [],
});
};

View File

@@ -55,6 +55,7 @@ describe('gdjs.TextInputRuntimeObject (using a PixiJS RuntimeGame with DOM eleme
objects: [],
instances: [],
variables: [],
usedResources: [],
});
};

View File

@@ -58,6 +58,7 @@ describe('gdjs.TileMapCollisionMaskRuntimeObject', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
setFramesPerSecond(runtimeScene, framePerSecond);
return runtimeScene;

View File

@@ -32,6 +32,7 @@ describe('gdjs.TopDownMovementRuntimeBehavior', function () {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
runtimeScene._timeManager.getElapsedTime = function () {
return timeDelta;

View File

@@ -39,6 +39,15 @@ SceneExtension::SceneExtension() {
GetAllConditions()["DoesSceneExist"].SetFunctionName(
"gdjs.evtTools.runtimeScene.doesSceneExist");
GetAllActions()["PrioritizeLoadingOfScene"].SetFunctionName(
"gdjs.evtTools.runtimeScene.prioritizeLoadingOfScene");
GetAllConditions()["AreSceneAssetsLoaded"].SetFunctionName(
"gdjs.evtTools.runtimeScene.areSceneAssetsLoaded");
GetAllConditions()["SceneLoadingProgress"].SetFunctionName(
"gdjs.evtTools.runtimeScene.getSceneLoadingProgress");
GetAllExpressions()["SceneLoadingProgress"].SetFunctionName(
"gdjs.evtTools.runtimeScene.getSceneLoadingProgress");
StripUnimplementedInstructionsAndExpressions();
}

View File

@@ -15,6 +15,7 @@
#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/ExternalEvents.h"
#include "GDCore/Project/ExternalLayout.h"
@@ -129,14 +130,26 @@ bool Exporter::ExportWholePixiProject(const ExportOptions &options) {
return false;
}
auto projectUsedResources =
gd::SceneResourcesFinder::FindProjectResources(exportedProject);
std::unordered_map<gd::String, std::set<gd::String>> 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);
helper.ExportProjectData(fs, exportedProject, codeOutputDir + "/data.js",
noRuntimeGameOptions, projectUsedResources,
scenesUsedResources);
includesFiles.push_back(codeOutputDir + "/data.js");
// Export a WebManifest with project metadata

View File

@@ -26,6 +26,7 @@
#include "GDCore/IDE/Events/UsedExtensionsFinder.h"
#include "GDCore/IDE/ExportedDependencyResolver.h"
#include "GDCore/IDE/Project/ProjectResourcesCopier.h"
#include "GDCore/IDE/Project/SceneResourcesFinder.h"
#include "GDCore/IDE/ProjectStripper.h"
#include "GDCore/IDE/SceneNameMangler.h"
#include "GDCore/Project/EventsBasedObject.h"
@@ -187,6 +188,17 @@ bool ExporterHelper::ExportProjectForPixiPreview(
previousTime = LogTimeSpent("Events code export", previousTime);
}
auto projectUsedResources =
gd::SceneResourcesFinder::FindProjectResources(exportedProject);
std::unordered_map<gd::String, std::set<gd::String>> 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 (objects groups...))
gd::ProjectStripper::StripProjectForExport(exportedProject);
@@ -234,8 +246,9 @@ bool ExporterHelper::ExportProjectForPixiPreview(
}
// Export the project
ExportProjectData(
fs, exportedProject, codeOutputDir + "/data.js", runtimeGameOptions);
ExportProjectData(fs, exportedProject, codeOutputDir + "/data.js",
runtimeGameOptions, projectUsedResources,
scenesUsedResources);
includesFiles.push_back(codeOutputDir + "/data.js");
previousTime = LogTimeSpent("Project data export", previousTime);
@@ -259,14 +272,17 @@ bool ExporterHelper::ExportProjectForPixiPreview(
gd::String ExporterHelper::ExportProjectData(
gd::AbstractFileSystem &fs,
const gd::Project &project,
gd::Project &project,
gd::String filename,
const gd::SerializerElement &runtimeGameOptions) {
const gd::SerializerElement &runtimeGameOptions,
std::set<gd::String> &projectUsedResources,
std::unordered_map<gd::String, std::set<gd::String>> &scenesUsedResources) {
fs.MkDir(fs.DirNameFrom(filename));
// 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 = " +
@@ -277,6 +293,35 @@ gd::String ExporterHelper::ExportProjectData(
return "";
}
void ExporterHelper::SerializeUsedResources(
gd::SerializerElement &rootElement,
std::set<gd::String> &projectUsedResources,
std::unordered_map<gd::String, std::set<gd::String>> &scenesUsedResources) {
auto serializeUsedResources =
[](gd::SerializerElement &element,
std::set<gd::String> &usedResources) -> void {
auto &resourcesElement = element.AddChild("usedResources");
resourcesElement.ConsiderAsArrayOf("resourceReference");
for (auto &resourceName : usedResources) {
auto &resourceElement = resourcesElement.AddChild("resourceReference");
resourceElement.SetAttribute("name", resourceName);
}
};
serializeUsedResources(rootElement, projectUsedResources);
auto &layoutsElement = rootElement.GetChild("layouts");
for (std::size_t layoutIndex = 0;
layoutIndex < layoutsElement.GetChildrenCount(); layoutIndex++) {
auto &layoutElement = layoutsElement.GetChild(layoutIndex);
const auto layoutName = layoutElement.GetStringAttribute("name");
auto &layoutUsedResources = scenesUsedResources[layoutName];
serializeUsedResources(layoutElement, layoutUsedResources);
}
}
bool ExporterHelper::ExportPixiIndexFile(
const gd::Project &project,
gd::String source,
@@ -621,6 +666,8 @@ void ExporterHelper::AddLibsInclude(bool pixiRenderers,
InsertUnique(includesFiles, "inputmanager.js");
InsertUnique(includesFiles, "jsonmanager.js");
InsertUnique(includesFiles, "Model3DManager.js");
InsertUnique(includesFiles, "ResourceLoader.js");
InsertUnique(includesFiles, "ResourceCache.js");
InsertUnique(includesFiles, "timemanager.js");
InsertUnique(includesFiles, "polygon.js");
InsertUnique(includesFiles, "runtimeobject.js");

View File

@@ -3,12 +3,13 @@
* Copyright 2008-2016 Florian Rival (Florian.Rival@gmail.com). All rights
* reserved. This project is released under the MIT License.
*/
#ifndef EXPORTER_HELPER_H
#define EXPORTER_HELPER_H
#pragma once
#include <map>
#include <set>
#include <string>
#include <vector>
#include <unordered_map>
#include "GDCore/String.h"
namespace gd {
@@ -264,11 +265,12 @@ class ExporterHelper {
* in gdjs.runtimeGameOptions \return Empty string if everything is ok,
* description of the error otherwise.
*/
static gd::String ExportProjectData(
gd::AbstractFileSystem &fs,
const gd::Project &project,
gd::String filename,
const gd::SerializerElement &runtimeGameOptions);
static gd::String
ExportProjectData(gd::AbstractFileSystem &fs, gd::Project &project,
gd::String filename,
const gd::SerializerElement &runtimeGameOptions,
std::set<gd::String> &projectUsedResources,
std::unordered_map<gd::String, std::set<gd::String>> &layersUsedResources);
/**
* \brief Copy all the resources of the project to to the export directory,
@@ -474,7 +476,12 @@ class ExporterHelper {
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.
private:
static void SerializeUsedResources(
gd::SerializerElement &rootElement,
std::set<gd::String> &projectUsedResources,
std::unordered_map<gd::String, std::set<gd::String>> &layersUsedResources);
};
} // namespace gdjs
#endif // EXPORTER_HELPER_H

View File

@@ -5,20 +5,21 @@
*/
namespace gdjs {
const logger = new gdjs.Logger('Model3DManager');
type OnProgressCallback = (loadedCount: integer, totalCount: integer) => void;
const resourceKinds: Array<ResourceKind> = ['model3D'];
/**
* Load GLB files (using `Three.js`), using the "model3D" resources
* registered in the game resources.
*/
export class Model3DManager {
export class Model3DManager implements gdjs.ResourceManager {
/**
* Map associating a resource name to the loaded Three.js model.
*/
private _loadedThreeModels = new Map<String, THREE_ADDONS.GLTF>();
private _loadedThreeModels = new gdjs.ResourceCache<THREE_ADDONS.GLTF>();
private _downloadedArrayBuffers = new gdjs.ResourceCache<ArrayBuffer>();
_resourcesLoader: RuntimeGameResourcesLoader;
_resources: Map<string, ResourceData>;
_resourceLoader: gdjs.ResourceLoader;
_loader: THREE_ADDONS.GLTFLoader | null = null;
_dracoLoader: THREE_ADDONS.DRACOLoader | null = null;
@@ -28,15 +29,10 @@ namespace gdjs {
/**
* @param resourceDataArray The resources data of the game.
* @param resourcesLoader The resources loader of the game.
* @param resourceLoader The resources loader of the game.
*/
constructor(
resourceDataArray: ResourceData[],
resourcesLoader: RuntimeGameResourcesLoader
) {
this._resources = new Map<string, ResourceData>();
this.setResources(resourceDataArray);
this._resourcesLoader = resourcesLoader;
constructor(resourceLoader: gdjs.ResourceLoader) {
this._resourceLoader = resourceLoader;
if (typeof THREE !== 'undefined') {
this._loader = new THREE_ADDONS.GLTFLoader();
@@ -69,17 +65,34 @@ namespace gdjs {
}
}
/**
* Update the resources data of the game. Useful for hot-reloading, should not be used otherwise.
*
* @param resourceDataArray The resources data of the game.
*/
setResources(resourceDataArray: ResourceData[]): void {
this._resources.clear();
for (const resourceData of resourceDataArray) {
if (resourceData.kind === 'model3D') {
this._resources.set(resourceData.name, resourceData);
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
async processResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find texture for resource "' + resourceName + '".'
);
return;
}
const loader = this._loader;
if (!loader) {
return;
}
const data = this._downloadedArrayBuffers.get(resource);
if (!data) {
return;
}
this._downloadedArrayBuffers.delete(resource);
try {
const gltf: THREE_ADDONS.GLTF = await loader.parseAsync(data, '');
this._loadedThreeModels.set(resource, gltf);
} catch (error) {
logger.error(
"Can't fetch the 3D model file " + resource.file + ', error: ' + error
);
}
}
@@ -88,42 +101,36 @@ namespace gdjs {
*
* Note that even if a file is already loaded, it will be reloaded (useful for hot-reloading,
* as files can have been modified without the editor knowing).
*
* @param onProgress The function called after each file is loaded.
* @param onComplete The function called when all file are loaded.
*/
async loadModels(onProgress: OnProgressCallback): Promise<integer> {
const loader = this._loader;
if (this._resources.size === 0 || !loader) {
return 0;
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find texture for resource "' + resourceName + '".'
);
return;
}
const loader = this._loader;
if (!loader) {
return;
}
const url = this._resourceLoader.getFullUrl(resource.file);
try {
const response = await fetch(url, {
credentials: this._resourceLoader.checkIfCredentialsRequired(url)
? 'include'
: 'omit',
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.arrayBuffer();
this._downloadedArrayBuffers.set(resource, data);
} catch (error) {
logger.error(
"Can't fetch the 3D model file " + resource.file + ', error: ' + error
);
}
let loadedCount = 0;
await Promise.all(
[...this._resources.values()].map(async (resource) => {
const url = this._resourcesLoader.getFullUrl(resource.file);
loader.withCredentials = this._resourcesLoader.checkIfCredentialsRequired(
url
);
try {
const gltf: THREE_ADDONS.GLTF = await loader.loadAsync(
url,
(event) => {}
);
this._loadedThreeModels.set(resource.name, gltf);
} catch (error) {
logger.error(
"Can't fetch the 3D model file " +
resource.file +
', error: ' +
error
);
}
loadedCount++;
onProgress(loadedCount, this._resources.size);
})
);
return loadedCount;
}
/**
@@ -135,7 +142,9 @@ namespace gdjs {
* @returns a 3D model if it exists.
*/
getModel(resourceName: string): THREE_ADDONS.GLTF {
return this._loadedThreeModels.get(resourceName) || this._invalidModel;
return (
this._loadedThreeModels.getFromName(resourceName) || this._invalidModel
);
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* GDevelop JS Platform
* Copyright 2013-present Florian Rival (Florian.Rival@gmail.com). All rights reserved.
* This project is released under the MIT License.
*/
namespace gdjs {
/**
* A cache of resources that helps ensuring that files are only downloaded
* once.
*/
export class ResourceCache<C> {
private _nameToContent = new Map<string, C>();
private _fileToContent = new Map<string, C>();
constructor() {}
/**
* Gives a fast access to asset content when they were pre-loaded and
* on-the-fly loading is not allowed.
*/
getFromName(name: string): C | null {
return this._nameToContent.get(name) || null;
}
get(resource: ResourceData): C | null {
let existingContent = this._nameToContent.get(resource.name);
if (existingContent) {
return existingContent;
}
// When several assets use the same file, it avoids to download it again.
existingContent = this._fileToContent.get(resource.file);
if (existingContent) {
this._nameToContent.set(resource.name, existingContent);
return existingContent;
}
return null;
}
set(resource: ResourceData, content: C) {
this._nameToContent.set(resource.name, content);
this._fileToContent.set(resource.file, content);
}
delete(resource: ResourceData) {
this._nameToContent.delete(resource.name);
this._fileToContent.delete(resource.file);
}
clear() {
this._nameToContent.clear();
this._fileToContent.clear();
}
}
}

View File

@@ -0,0 +1,556 @@
/*
* GDevelop JS Platform
* Copyright 2013-2023 Florian Rival (Florian.Rival@gmail.com). All rights reserved.
* This project is released under the MIT License.
*/
namespace gdjs {
const logger = new gdjs.Logger('ResourceLoader');
const addSearchParameterToUrl = (
url: string,
urlEncodedParameterName: string,
urlEncodedValue: string
) => {
if (url.startsWith('data:') || url.startsWith('blob:')) {
// blob/data protocol does not support search parameters, which are useless anyway.
return url;
}
const separator = url.indexOf('?') === -1 ? '?' : '&';
return url + separator + urlEncodedParameterName + '=' + urlEncodedValue;
};
const checkIfIsGDevelopCloudBucketUrl = (url: string): boolean => {
return (
url.startsWith('https://project-resources.gdevelop.io/') ||
url.startsWith('https://project-resources-dev.gdevelop.io/')
);
};
/**
* A task of pre-loading resources used by a scene.
*
* A Promise can't be used instead of this class because a Promise will start
* as soon as possible. It would flood the server with downloading requests
* and make impossible to finely tune in which order scenes are actually
* downloaded.
*/
class SceneLoadingTask {
sceneName: string;
private onProgressCallbacks: Array<(count: number, total: number) => void>;
private onFinishCallbacks: Array<() => void>;
private isFinished = false;
constructor(sceneName: string) {
this.sceneName = sceneName;
this.onProgressCallbacks = new Array<
(count: number, total: number) => void
>();
this.onFinishCallbacks = new Array<() => void>();
}
registerCallback(
onFinish: () => void,
onProgress?: (count: number, total: number) => void
) {
if (this.isFinished) {
onFinish();
return;
}
this.onFinishCallbacks.push(onFinish);
if (onProgress) {
this.onProgressCallbacks.push(onProgress);
}
}
onProgress(count: number, total: number) {
for (const onProgress of this.onProgressCallbacks) {
onProgress(count, total);
}
}
onFinish() {
this.isFinished = true;
for (const onFinish of this.onFinishCallbacks) {
onFinish();
}
}
}
/**
* Pre-load resources of any kind needed for a game or a scene.
*/
export class ResourceLoader {
_runtimeGame: RuntimeGame;
/**
* All the resource of a game by resource name.
*/
private _resources: Map<string, ResourceData>;
/**
* Resources needed for any scene. Typically, they are resources from
* global objects.
*/
private _globalResources: Array<string>;
/**
* Resources by scene names.
*/
private _sceneResources: Map<string, Array<string>>;
/**
* Keep track of which scene whose resources has already be pre-loaded.
*/
private _sceneNamesToLoad: Set<string>;
/**
* Keep track of which scene whose resources has already be loaded.
*/
private _sceneNamesToMakeReady: Set<string>;
/**
* A queue of scenes whose resources are still to be pre-loaded.
*/
private _sceneToLoadQueue: Array<SceneLoadingTask> = new Array<
SceneLoadingTask
>();
/**
* The resource managers that actually download and remember downloaded
* content.
*/
_resourceManagersMap: Map<ResourceKind, ResourceManager>;
private _imageManager: ImageManager;
private _soundManager: SoundManager;
private _fontManager: FontManager;
private _jsonManager: JsonManager;
private _model3DManager: Model3DManager;
private _bitmapFontManager: BitmapFontManager;
/**
* Only used by events.
*/
private currentLoadingSceneName: string = '';
/**
* Only used by events.
*/
private currentSceneLoadingProgress: float = 0;
/**
* @param runtimeGame The game.
* @param resourceDataArray The resources data of the game.
* @param globalResources The resources needed for any layer.
* @param layoutDataArray The resources used by each layer.
*/
constructor(
runtimeGame: RuntimeGame,
resourceDataArray: ResourceData[],
globalResources: Array<string>,
layoutDataArray: Array<LayoutData>
) {
this._runtimeGame = runtimeGame;
this._resources = new Map<string, ResourceData>();
this._globalResources = globalResources;
// These 3 attributes are filled by `setResources`.
this._sceneResources = new Map<string, Array<string>>();
this._sceneNamesToLoad = new Set<string>();
this._sceneNamesToMakeReady = new Set<string>();
this.setResources(resourceDataArray, globalResources, layoutDataArray);
this._imageManager = new gdjs.ImageManager(this);
this._soundManager = new gdjs.SoundManager(this);
this._fontManager = new gdjs.FontManager(this);
this._jsonManager = new gdjs.JsonManager(this);
this._bitmapFontManager = new gdjs.BitmapFontManager(
this,
this._imageManager
);
this._model3DManager = new gdjs.Model3DManager(this);
const resourceManagers: Array<ResourceManager> = [
this._imageManager,
this._soundManager,
this._fontManager,
this._jsonManager,
this._bitmapFontManager,
this._model3DManager,
];
this._resourceManagersMap = new Map<ResourceKind, ResourceManager>();
for (const resourceManager of resourceManagers) {
for (const resourceKind of resourceManager.getResourceKinds()) {
this._resourceManagersMap.set(resourceKind, resourceManager);
}
}
}
/**
* Update the resources data of the game. Useful for hot-reloading, should
* not be used otherwise.
*/
setResources(
resourceDataArray: ResourceData[],
globalResources: Array<string>,
layoutDataArray: Array<LayoutData>
): void {
this._globalResources = globalResources;
this._sceneResources.clear();
this._sceneNamesToLoad.clear();
this._sceneNamesToMakeReady.clear();
for (const layoutData of layoutDataArray) {
this._sceneResources.set(
layoutData.name,
layoutData.usedResources.map((resource) => resource.name)
);
this._sceneNamesToLoad.add(layoutData.name);
this._sceneNamesToMakeReady.add(layoutData.name);
}
// 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];
this._sceneToLoadQueue.push(new SceneLoadingTask(layoutData.name));
}
this._resources.clear();
for (const resourceData of resourceDataArray) {
this._resources.set(resourceData.name, resourceData);
}
}
async loadAllResources(
onProgress: (loadingCount: integer, totalCount: integer) => void
): Promise<void> {
let loadedCount = 0;
await Promise.all(
[...this._resources.values()].map(async (resource) => {
await this._loadResource(resource);
await this._processResource(resource);
loadedCount++;
onProgress(loadedCount, this._resources.size);
})
);
this._sceneNamesToLoad.clear();
this._sceneNamesToMakeReady.clear();
}
/**
* Load the resources that are needed to launch the first scene.
*/
async loadGlobalAndFirstSceneResources(
firstSceneName: string,
onProgress: (count: number, total: number) => void
): Promise<void> {
const sceneResources = this._sceneResources.get(firstSceneName);
if (!sceneResources) {
logger.warn(
'Can\'t load resource for unknown scene: "' + firstSceneName + '".'
);
return;
}
let loadedCount = 0;
const resources = [...this._globalResources, ...sceneResources.values()];
await Promise.all(
resources.map(async (resourceName) => {
const resource = this._resources.get(resourceName);
if (!resource) {
logger.warn('Unable to find resource "' + resourceName + '".');
return;
}
await this._loadResource(resource);
await this._processResource(resource);
loadedCount++;
onProgress(loadedCount, resources.length);
})
);
this._setSceneAssetsLoaded(firstSceneName);
this._setSceneAssetsReady(firstSceneName);
}
/**
* Load each scene in order.
*
* This is done in background to try to avoid loading screens when changing
* scenes.
*/
async loadAllSceneInBackground(): Promise<void> {
while (this._sceneToLoadQueue.length > 0) {
const task = this._sceneToLoadQueue[this._sceneToLoadQueue.length - 1];
if (task === undefined) {
continue;
}
this.currentLoadingSceneName = task.sceneName;
if (!this.areSceneAssetsLoaded(task.sceneName)) {
await this._doLoadSceneResources(
task.sceneName,
async (count, total) => task.onProgress(count, total)
);
// 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();
}
}
this.currentLoadingSceneName = '';
}
private async _doLoadSceneResources(
sceneName: string,
onProgress?: (count: number, total: number) => Promise<void>
): Promise<void> {
const sceneResources = this._sceneResources.get(sceneName);
if (!sceneResources) {
logger.warn(
'Can\'t load resource for unknown scene: "' + sceneName + '".'
);
return;
}
let loadedCount = 0;
await Promise.all(
[...sceneResources.values()].map(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 / this._resources.size;
onProgress && (await onProgress(loadedCount, this._resources.size));
})
);
this._setSceneAssetsLoaded(sceneName);
}
private async _loadResource(resource: ResourceData): Promise<void> {
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);
}
/**
* Load and process a scene that is needed right away.
*
* The renderer will show a loading screen while its done.
*/
async loadAndProcessSceneResources(
sceneName: string,
onProgress?: (count: number, total: number) => Promise<void>
): Promise<void> {
if (this.areSceneAssetsReady(sceneName)) {
return;
}
await this.loadSceneResources(sceneName, onProgress);
const sceneResources = this._sceneResources.get(sceneName);
if (!sceneResources) {
logger.warn(
'Can\'t load resource for unknown scene: "' + sceneName + '".'
);
return;
}
let parsedCount = 0;
for (const resourceName of sceneResources) {
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, sceneResources.length));
}
this._setSceneAssetsReady(sceneName);
}
/**
* Load a scene resources without parsing them.
*
* When another scene resources are loading in background, it waits for
* all its resources to be loaded before loading resources of the given
* scene.
*/
async loadSceneResources(
sceneName: string,
onProgress?: (count: number, total: number) => void
): Promise<void> {
const task = this._prioritizeScene(sceneName);
return new Promise<void>((resolve, reject) => {
if (!task) {
resolve();
return;
}
task.registerCallback(() => {
resolve();
}, onProgress);
});
}
/**
* 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 taskIndex = this._sceneToLoadQueue.findIndex(
(task) => task.sceneName === sceneName
);
if (taskIndex < 0) {
// The scene is already loaded.
return null;
}
const task = this._sceneToLoadQueue[taskIndex];
this._sceneToLoadQueue.splice(taskIndex, 1);
this._sceneToLoadQueue.push(task);
return task;
}
private async _processResource(resource: ResourceData): Promise<void> {
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;
}
/**
* @returns true when all the resources of the given scene are loaded
* (but maybe not parsed).
*/
areSceneAssetsLoaded(sceneName: string): boolean {
return !this._sceneNamesToLoad.has(sceneName);
}
/**
* @returns true when all the resources of the given scene are loaded and
* parsed.
*/
areSceneAssetsReady(sceneName: string): boolean {
return !this._sceneNamesToMakeReady.has(sceneName);
}
private _setSceneAssetsLoaded(sceneName: string): void {
this._sceneNamesToLoad.delete(sceneName);
}
private _setSceneAssetsReady(sceneName: string): void {
this._sceneNamesToMakeReady.delete(sceneName);
}
getResource(resourceName: string): ResourceData | null {
return this._resources.get(resourceName) || null;
}
// Helper methods used when resources are loaded from an URL.
/**
* Complete the given URL with any specific parameter required to access
* the resource (this can be for example a token needed to access the resource).
*/
getFullUrl(url: string) {
const { gdevelopResourceToken } = this._runtimeGame._options;
if (!gdevelopResourceToken) return url;
if (!checkIfIsGDevelopCloudBucketUrl(url)) return url;
return addSearchParameterToUrl(
url,
'gd_resource_token',
encodeURIComponent(gdevelopResourceToken)
);
}
/**
* Return true if the specified URL must be loaded with cookies ("credentials")
* sent to grant access to them.
*/
checkIfCredentialsRequired(url: string) {
if (this._runtimeGame._options.gdevelopResourceToken) return false;
// Any resource stored on the GDevelop Cloud buckets needs the "credentials" of the user,
// i.e: its gdevelop.io cookie, to be passed.
// Note that this is only useful during previews.
if (checkIfIsGDevelopCloudBucketUrl(url)) return true;
// For other resources, use the default way of loading resources ("anonymous" or "same-site").
return false;
}
/**
* Get the gdjs.SoundManager of the RuntimeGame.
* @return The sound manager.
*/
getSoundManager(): gdjs.HowlerSoundManager {
return this._soundManager;
}
/**
* Get the gdjs.ImageManager of the RuntimeGame.
* @return The image manager.
*/
getImageManager(): gdjs.PixiImageManager {
return this._imageManager;
}
/**
* Get the gdjs.FontManager of the RuntimeGame.
* @return The font manager.
*/
getFontManager(): gdjs.FontFaceObserverFontManager {
return this._fontManager;
}
/**
* Get the gdjs.BitmapFontManager of the RuntimeGame.
* @return The bitmap font manager.
*/
getBitmapFontManager(): gdjs.BitmapFontManager {
return this._bitmapFontManager;
}
/**
* Get the JSON manager of the game, used to load JSON from game
* resources.
* @return The json manager for the game
*/
getJsonManager(): gdjs.JsonManager {
return this._jsonManager;
}
/**
* Get the 3D model manager of the game, used to load 3D model from game
* resources.
* @return The 3D model manager for the game
*/
getModel3DManager(): gdjs.Model3DManager {
return this._model3DManager;
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* GDevelop JS Platform
* Copyright 2013-2023 Florian Rival (Florian.Rival@gmail.com). All rights reserved.
* This project is released under the MIT License.
*/
namespace gdjs {
/**
* A resource managers that download and remember downloaded content for one
* kind of resource.
*/
export interface ResourceManager {
/**
* Load the specified resource.
*
* This method will be run during the game. It should only do light tasks
* like file downloading.
*/
loadResource(resourceName: string): Promise<void>;
/**
* Process the specified resource.
*
* This method will only be run while loading screen is shown. It can do
* heavy tasks like parsing data.
*/
processResource(resourceName: string): Promise<void>;
/**
* Return the kind of resources handled by this manager.
*/
getResourceKinds(): Array<ResourceKind>;
}
}

View File

@@ -311,77 +311,82 @@ namespace gdjs {
return Promise.all(reloadPromises);
}
_hotReloadRuntimeGame(
async _hotReloadRuntimeGame(
oldProjectData: ProjectData,
newProjectData: ProjectData,
changedRuntimeBehaviors: ChangedRuntimeBehavior[],
runtimeGame: gdjs.RuntimeGame
): Promise<void> {
return new Promise((resolve) => {
// Update project data and re-load assets (sound/image/font/json managers
// will take care of reloading only what is needed).
runtimeGame.setProjectData(newProjectData);
runtimeGame.loadAllAssets(() => {
this._hotReloadVariablesContainer(
oldProjectData.variables,
newProjectData.variables,
runtimeGame.getVariables()
);
// Reload runtime scenes
const sceneStack = runtimeGame.getSceneStack();
sceneStack._stack.forEach((runtimeScene) => {
const oldLayoutData = oldProjectData.layouts.filter(
(layoutData) => layoutData.name === runtimeScene.getName()
)[0];
const newLayoutData = newProjectData.layouts.filter(
(layoutData) => layoutData.name === runtimeScene.getName()
)[0];
if (oldLayoutData && newLayoutData) {
this._hotReloadRuntimeScene(
oldLayoutData,
newLayoutData,
changedRuntimeBehaviors,
runtimeScene
);
} else {
// A scene was removed. Not hot-reloading this.
this._logs.push({
kind: 'error',
message:
'Scene ' +
oldLayoutData.name +
' was removed. A fresh preview should be launched.',
});
}
});
// Reload changes in external layouts
newProjectData.externalLayouts.forEach((newExternalLayoutData) => {
const oldExternalLayoutData = oldProjectData.externalLayouts.filter(
(externalLayoutData) =>
externalLayoutData.name === newExternalLayoutData.name
)[0];
if (
oldExternalLayoutData &&
// Check if there are actual changes, to avoid useless work trying to
// hot-reload all the scenes.
!HotReloader.deepEqual(
oldExternalLayoutData,
newExternalLayoutData
)
) {
sceneStack._stack.forEach((runtimeScene) => {
this._hotReloadRuntimeSceneInstances(
oldExternalLayoutData.instances,
newExternalLayoutData.instances,
runtimeScene
);
});
}
});
resolve();
const sceneStack = runtimeGame.getSceneStack();
const currentScene = sceneStack.getCurrentScene();
if (!currentScene) {
// It can't actually happen.
this._logs.push({
kind: 'error',
message: "Can't hot-reload as no scene are opened.",
});
return;
}
// Update project data and re-load assets (sound/image/font/json managers
// will take care of reloading only what is needed).
runtimeGame.setProjectData(newProjectData);
await runtimeGame.loadFirstAssetsAndStartBackgroundLoading(
currentScene.getName(),
() => {}
);
this._hotReloadVariablesContainer(
oldProjectData.variables,
newProjectData.variables,
runtimeGame.getVariables()
);
// Reload runtime scenes
sceneStack._stack.forEach((runtimeScene) => {
const oldLayoutData = oldProjectData.layouts.filter(
(layoutData) => layoutData.name === runtimeScene.getName()
)[0];
const newLayoutData = newProjectData.layouts.filter(
(layoutData) => layoutData.name === runtimeScene.getName()
)[0];
if (oldLayoutData && newLayoutData) {
this._hotReloadRuntimeScene(
oldLayoutData,
newLayoutData,
changedRuntimeBehaviors,
runtimeScene
);
} else {
// A scene was removed. Not hot-reloading this.
this._logs.push({
kind: 'error',
message:
'Scene ' +
oldLayoutData.name +
' was removed. A fresh preview should be launched.',
});
}
});
// Reload changes in external layouts
newProjectData.externalLayouts.forEach((newExternalLayoutData) => {
const oldExternalLayoutData = oldProjectData.externalLayouts.filter(
(externalLayoutData) =>
externalLayoutData.name === newExternalLayoutData.name
)[0];
if (
oldExternalLayoutData &&
// Check if there are actual changes, to avoid useless work trying to
// hot-reload all the scenes.
!HotReloader.deepEqual(oldExternalLayoutData, newExternalLayoutData)
) {
sceneStack._stack.forEach((runtimeScene) => {
this._hotReloadRuntimeSceneInstances(
oldExternalLayoutData.instances,
newExternalLayoutData.instances,
runtimeScene
);
});
}
});
}

View File

@@ -326,6 +326,36 @@ namespace gdjs {
): boolean => {
return runtimeScene.getGame().hasScene(sceneName);
};
/**
* Preload a scene assets as soon as possible in background.
*/
export const prioritizeLoadingOfScene = (
runtimeScene: gdjs.RuntimeScene,
sceneName: string
): void => {
runtimeScene.getGame().prioritizeLoadingOfScene(sceneName);
};
/**
* @return The progress of assets loading in background for a scene (between 0 and 1).
*/
export const getSceneLoadingProgress = (
runtimeScene: gdjs.RuntimeScene,
sceneName: string
): float => {
return runtimeScene.getGame().getSceneLoadingProgress(sceneName);
};
/**
* Check if scene assets have finished to load in background.
*/
export const areSceneAssetsLoaded = (
runtimeScene: gdjs.RuntimeScene,
sceneName: string
): boolean => {
return runtimeScene.getGame().areSceneAssetsLoaded(sceneName);
};
}
}
}

View File

@@ -6,46 +6,29 @@
namespace gdjs {
const logger = new gdjs.Logger('Font manager');
const resourceKinds: Array<ResourceKind> = ['font'];
/**
* FontFaceObserverFontManager loads fonts (using `FontFace` or `fontfaceobserver` library)
* from the game resources (see `loadFonts`), and allow to access to
* the font families of the loaded fonts during the game (see `getFontFamily`).
*/
export class FontFaceObserverFontManager {
_resourcesLoader: RuntimeGameResourcesLoader;
_resources: Map<string, ResourceData>;
export class FontFaceObserverFontManager implements gdjs.ResourceManager {
_resourceLoader: gdjs.ResourceLoader;
// Associate font resource names to the loaded font family
_loadedFontFamily: { [key: string]: string } = {};
// Associate font resource names to the resources, for faster access
_loadedFonts: { [key: string]: ResourceData } = {};
_filenameToFontFamily: { [key: string]: string } = {};
_loadedFontFamily = new gdjs.ResourceCache<string>();
_loadedFontFamilySet = new Set<string>();
/**
* @param resources The resources data of the game.
* @param resourcesLoader The resources loader of the game.
* @param resourceLoader The resources loader of the game.
*/
constructor(
resourceDataArray: ResourceData[],
resourcesLoader: RuntimeGameResourcesLoader
) {
this._resources = new Map<string, ResourceData>();
this.setResources(resourceDataArray);
this._resourcesLoader = resourcesLoader;
constructor(resourceLoader: gdjs.ResourceLoader) {
this._resourceLoader = resourceLoader;
}
// Cache the result of transforming a filename to a font family - useful to avoid duplicates.
/**
* Update the resources data of the game. Useful for hot-reloading, should not be used otherwise.
*
* @param resources The resources data of the game.
*/
setResources(resourceDataArray: ResourceData[]): void {
this._resources.clear();
for (const resourceData of resourceDataArray) {
if (resourceData.kind === 'font') {
this._resources.set(resourceData.name, resourceData);
}
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
/**
@@ -58,10 +41,7 @@ namespace gdjs {
* or "Arial" if not loaded.
*/
getFontFamily(resourceName: string): string {
if (this._loadedFontFamily[resourceName]) {
return this._loadedFontFamily[resourceName];
}
return 'Arial';
return this._loadedFontFamily.getFromName(resourceName) || 'Arial';
}
/**
@@ -76,10 +56,8 @@ namespace gdjs {
* @returns The file of the font resource.
*/
getFontFile(resourceName: string): string {
if (this._loadedFonts[resourceName]) {
return this._loadedFonts[resourceName].file || '';
}
return resourceName;
const resource = this._resourceLoader.getResource(resourceName);
return resource ? resource.file || '' : resourceName;
}
/**
@@ -92,26 +70,20 @@ namespace gdjs {
* @param filename The filename of the font.
* @returns The font family to be used for this font resource.
*/
_getFontFamilyFromFilename(filename: string): string {
if (this._filenameToFontFamily[filename]) {
return this._filenameToFontFamily[filename];
}
_getFontFamilyFromFilename(resource: ResourceData): string {
// Replaces all non-alphanumeric characters with dashes to ensure no issues when
// referring to this font family (see https://github.com/4ian/GDevelop/issues/1521).
let baseSlugifiedName =
'gdjs_font_' + filename.toLowerCase().replace(/[^\w]/gi, '-');
'gdjs_font_' + resource.file.toLowerCase().replace(/[^\w]/gi, '-');
// Ensure the generated font family is unique.
const slugifiedName = baseSlugifiedName;
let uniqueSuffix = 2;
while (!!this._filenameToFontFamily[baseSlugifiedName]) {
while (this._loadedFontFamilySet.has(baseSlugifiedName)) {
baseSlugifiedName = baseSlugifiedName + '-' + uniqueSuffix;
uniqueSuffix++;
}
// Cache the result to avoid collision with a similar slugified name for another filename.
return (this._filenameToFontFamily[filename] = slugifiedName);
return slugifiedName;
}
/**
@@ -131,8 +103,8 @@ namespace gdjs {
// @ts-ignore
if (typeof FontFace !== 'undefined') {
// Load the given font using CSS Font Loading API.
return fetch(this._resourcesLoader.getFullUrl(src), {
credentials: this._resourcesLoader.checkIfCredentialsRequired(src)
return fetch(this._resourceLoader.getFullUrl(src), {
credentials: this._resourceLoader.checkIfCredentialsRequired(src)
? // Any resource stored on the GDevelop Cloud buckets needs the "credentials" of the user,
// i.e: its gdevelop.io cookie, to be passed.
'include'
@@ -184,59 +156,46 @@ namespace gdjs {
}
}
async processResource(resourceName: string): Promise<void> {
// Do nothing because fonts are light enough to be parsed in background.
}
/**
* Load the specified resources, so that fonts are loaded and can then be
* used by using the font family returned by getFontFamily.
* @param onProgress Callback called each time a new file is loaded.
*/
async loadFonts(
onProgress: (loadedCount: integer, totalCount: integer) => void
): Promise<integer> {
// Construct the list of files to be loaded.
// For one loaded file, it can have one or more resources
// that use it.
const filesResources: { [key: string]: ResourceData[] } = {};
for (const res of this._resources.values()) {
if (res.file) {
if (!!this._loadedFonts[res.name]) {
continue;
}
filesResources[res.file] = filesResources[res.file]
? filesResources[res.file].concat(res)
: [res];
}
}
const totalCount = Object.keys(filesResources).length;
if (totalCount === 0) {
return 0;
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn('Unable to find font for resource "' + resourceName + '".');
return;
}
let loadingCount = 0;
await Promise.all(
Object.keys(filesResources).map(async (file) => {
const fontFamily = this._getFontFamilyFromFilename(file);
const fontResources = filesResources[file];
try {
await this._loadFont(fontFamily, file);
} catch (error) {
logger.error(
'Error loading font resource "' +
fontResources[0].name +
'" (file: ' +
file +
'): ' +
(error.message || 'Unknown error')
);
}
fontResources.forEach((resource) => {
this._loadedFontFamily[resource.name] = fontFamily;
this._loadedFonts[resource.name] = resource;
});
loadingCount++;
onProgress(loadingCount, totalCount);
})
);
return totalCount;
if (this._loadedFontFamily.get(resource)) {
return;
}
const file = resource.file;
if (!file) {
return;
}
const fontFamily = this._getFontFamilyFromFilename(resource);
// Cache the result to avoid collision with a similar slugified name for another filename.
this._loadedFontFamily.set(resource, fontFamily);
this._loadedFontFamilySet.add(fontFamily);
try {
await this._loadFont(fontFamily, file);
} catch (error) {
logger.error(
'Error loading font resource "' +
resource.name +
'" (file: ' +
file +
'): ' +
(error.message || 'Unknown error')
);
}
}
}

View File

@@ -7,6 +7,8 @@
namespace gdjs {
const logger = new gdjs.Logger('Audio manager');
const resourceKinds: Array<ResourceKind> = ['audio'];
const HowlParameters: HowlOptions = {
preload: true,
onplayerror: (_, error) =>
@@ -364,9 +366,8 @@ namespace gdjs {
* of all sounds being played.
*/
export class HowlerSoundManager {
_loadedMusics: Record<string, Howl> = {};
_loadedSounds: Record<string, Howl> = {};
_resources: Map<string, ResourceData>;
_loadedMusics = new gdjs.ResourceCache<Howl>();
_loadedSounds = new gdjs.ResourceCache<Howl>();
_availableResources: Record<string, ResourceData> = {};
_globalVolume: float = 100;
_sounds: Record<integer, HowlerSound> = {};
@@ -378,19 +379,14 @@ namespace gdjs {
_pausedSounds: HowlerSound[] = [];
_paused: boolean = false;
_resourcesLoader: RuntimeGameResourcesLoader;
_resourceLoader: gdjs.ResourceLoader;
/**
* @param resources The resources data of the game.
* @param resourcesLoader The resources loader of the game.
* @param resourceLoader The resources loader of the game.
*/
constructor(
resourceDataArray: ResourceData[],
resourcesLoader: RuntimeGameResourcesLoader
) {
this._resources = new Map<string, ResourceData>();
this.setResources(resourceDataArray);
this._resourcesLoader = resourcesLoader;
constructor(resourceLoader: gdjs.ResourceLoader) {
this._resourceLoader = resourceLoader;
const that = this;
document.addEventListener('deviceready', function () {
@@ -437,18 +433,8 @@ namespace gdjs {
});
}
/**
* Update the resources data of the game. Useful for hot-reloading, should not be used otherwise.
*
* @param resources The resources data of the game.
*/
setResources(resourceDataArray: ResourceData[]): void {
this._resources.clear();
for (const resourceData of resourceDataArray) {
if (resourceData.kind === 'audio') {
this._resources.set(resourceData.name, resourceData);
}
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
/**
@@ -472,17 +458,19 @@ namespace gdjs {
* file is associated to the given name, then the name will be considered as a
* filename and will be returned.
*
* @return The associated filename
* @return The associated resource
*/
private _getFileFromSoundName(soundName: string): string {
if (
this._availableResources.hasOwnProperty(soundName) &&
this._availableResources[soundName].file
) {
return this._availableResources[soundName].file;
}
return soundName;
}
private _getAudioResource = (resourceName: string): ResourceData => {
const resource = this._resourceLoader.getResource(resourceName);
return resource && this.getResourceKinds().includes(resource.kind)
? resource
: ({
file: resourceName,
kind: 'audio',
metadata: '',
name: resourceName,
} as ResourceData);
};
/**
* Store the sound in the specified array, put it at the first index that
@@ -524,18 +512,20 @@ namespace gdjs {
loop: boolean,
rate: float
): HowlerSound {
const soundFile = this._getFileFromSoundName(soundName);
const cacheContainer = isMusic ? this._loadedMusics : this._loadedSounds;
const resource = this._getAudioResource(soundName);
if (!cacheContainer.hasOwnProperty(soundFile)) {
cacheContainer[soundFile] = new Howl(
let howl = cacheContainer.get(resource);
if (!howl) {
const fileName = resource ? resource.file : soundName;
howl = new Howl(
Object.assign(
{
src: [this._resourcesLoader.getFullUrl(soundFile)],
src: [this._resourceLoader.getFullUrl(fileName)],
html5: isMusic,
xhr: {
withCredentials: this._resourcesLoader.checkIfCredentialsRequired(
soundFile
withCredentials: this._resourceLoader.checkIfCredentialsRequired(
fileName
),
},
// Cache the sound with no volume. This avoids a bug where it plays at full volume
@@ -545,14 +535,10 @@ namespace gdjs {
HowlParameters
)
);
cacheContainer.set(resource, howl);
}
return new gdjs.HowlerSound(
cacheContainer[soundFile],
volume,
loop,
rate
);
return new gdjs.HowlerSound(howl, volume, loop, rate);
}
/**
@@ -561,27 +547,32 @@ namespace gdjs {
* @param isMusic True if a music, false if a sound.
*/
loadAudio(soundName: string, isMusic: boolean) {
const soundFile = this._getFileFromSoundName(soundName);
const cacheContainer = isMusic ? this._loadedMusics : this._loadedSounds;
const resource = this._getAudioResource(soundName);
// Do not reload if it is already loaded.
if (cacheContainer.hasOwnProperty(soundFile)) return;
if (cacheContainer.get(resource)) {
return;
}
cacheContainer[soundFile] = new Howl(
Object.assign(
{
src: [this._resourcesLoader.getFullUrl(soundFile)],
html5: isMusic,
xhr: {
withCredentials: this._resourcesLoader.checkIfCredentialsRequired(
soundFile
),
cacheContainer.set(
resource,
new Howl(
Object.assign(
{
src: [this._resourceLoader.getFullUrl(resource.file)],
html5: isMusic,
xhr: {
withCredentials: this._resourceLoader.checkIfCredentialsRequired(
resource.file
),
},
// Cache the sound with no volume. This avoids a bug where it plays at full volume
// for a split second before setting its correct volume.
volume: 0,
},
// Cache the sound with no volume. This avoids a bug where it plays at full volume
// for a split second before setting its correct volume.
volume: 0,
},
HowlParameters
HowlParameters
)
)
);
}
@@ -592,15 +583,17 @@ namespace gdjs {
* @param isMusic True if a music, false if a sound.
*/
unloadAudio(soundName: string, isMusic: boolean) {
const soundFile = this._getFileFromSoundName(soundName);
const cacheContainer = isMusic ? this._loadedMusics : this._loadedSounds;
const resource = this._getAudioResource(soundName);
if (!cacheContainer[soundFile]) return;
const howl = cacheContainer.get(resource);
if (!howl) {
return;
}
// Make sure any sound using the howl is deleted so
// that the howl can be garbage collected
// and no weird "zombies" using the unloaded howl can exist.
const howl = cacheContainer[soundFile];
function clearContainer(howlerSoundContainer: HowlerSound[]) {
for (let i in howlerSoundContainer) {
if (
@@ -620,8 +613,8 @@ namespace gdjs {
clearContainer(Object.values(this._sounds));
clearContainer(this._pausedSounds);
cacheContainer[soundFile].unload();
delete cacheContainer[soundFile];
howl.unload();
cacheContainer.delete(resource);
}
/**
@@ -638,8 +631,8 @@ namespace gdjs {
this._sounds = {};
this._musics = {};
this._pausedSounds.length = 0;
this._loadedMusics = {};
this._loadedSounds = {};
this._loadedMusics.clear();
this._loadedSounds.clear();
}
playSound(soundName: string, loop: boolean, volume: float, pitch: float) {
@@ -763,31 +756,24 @@ namespace gdjs {
this._pausedSounds.length = 0;
}
async preloadAudio(
onProgress: (loadedCount: integer, totalCount: integer) => void,
resources?: ResourceData[]
): Promise<integer> {
// Construct the list of files to be loaded.
// For one loaded file, it can have one or more resources
// that use it.
const files = {};
for (const res of resources || this._resources.values()) {
if (res.file) {
if (!!this._availableResources[res.name]) {
continue;
}
async processResource(resourceName: string): Promise<void> {
// Do nothing because sounds are light enough to be parsed in background.
}
this._availableResources[res.name] = res;
files[res.file] = (files[res.file] || []).concat(res);
}
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find audio for resource "' + resourceName + '".'
);
return;
}
if (resource.file) {
if (this._availableResources[resource.name]) {
return;
}
const filesToLoad = Object.keys(files);
const totalCount = filesToLoad.length;
if (totalCount === 0) {
// Nothing to load.
return 0;
this._availableResources[resource.name] = resource;
}
const preloadAudioFile = (
@@ -798,12 +784,12 @@ namespace gdjs {
const container = isMusic ? this._loadedMusics : this._loadedSounds;
container[file] = new Howl(
Object.assign({}, HowlParameters, {
src: [this._resourcesLoader.getFullUrl(file)],
src: [this._resourceLoader.getFullUrl(file)],
onload: resolve,
onloaderror: (soundId: number, error?: string) => reject(error),
html5: isMusic,
xhr: {
withCredentials: this._resourcesLoader.checkIfCredentialsRequired(
withCredentials: this._resourceLoader.checkIfCredentialsRequired(
file
),
},
@@ -815,58 +801,49 @@ namespace gdjs {
});
};
let loadedCount: integer = 0;
await Promise.all(
filesToLoad.map(async (file) => {
const fileData = files[file][0];
const file = resource.file;
if (resource.preloadAsMusic) {
try {
await preloadAudioFile(file, /* isMusic= */ true);
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
}
if (fileData.preloadAsMusic) {
try {
await preloadAudioFile(file, /* isMusic= */ true);
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
}
if (fileData.preloadAsSound) {
try {
await preloadAudioFile(file, /* isMusic= */ false);
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
} else if (fileData.preloadInCache) {
// preloading as sound already does a XHR request, hence "else if"
try {
await new Promise((resolve, reject) => {
const sound = new XMLHttpRequest();
sound.withCredentials = this._resourcesLoader.checkIfCredentialsRequired(
file
);
sound.addEventListener('load', resolve);
sound.addEventListener('error', (_) =>
reject('XHR error: ' + file)
);
sound.addEventListener('abort', (_) =>
reject('XHR abort: ' + file)
);
sound.open('GET', this._resourcesLoader.getFullUrl(file));
sound.send();
});
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
}
loadedCount++;
onProgress(loadedCount, totalCount);
})
);
return totalCount;
if (resource.preloadAsSound) {
try {
await preloadAudioFile(file, /* isMusic= */ false);
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
} else if (resource.preloadInCache) {
// preloading as sound already does a XHR request, hence "else if"
try {
await new Promise((resolve, reject) => {
const sound = new XMLHttpRequest();
sound.withCredentials = this._resourceLoader.checkIfCredentialsRequired(
file
);
sound.addEventListener('load', resolve);
sound.addEventListener('error', (_) =>
reject('XHR error: ' + file)
);
sound.addEventListener('abort', (_) =>
reject('XHR abort: ' + file)
);
sound.open('GET', this._resourceLoader.getFullUrl(file));
sound.send();
});
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
}
}
}

View File

@@ -12,6 +12,7 @@ namespace gdjs {
content: Object | null
) => void;
const resourceKinds: Array<ResourceKind> = ['json', 'tilemap', 'tileset'];
/**
* JsonManager loads json files (using `XMLHttpRequest`), using the "json" resources
* registered in the game resources.
@@ -20,42 +21,22 @@ namespace gdjs {
* You should properly handle errors, and give the developer/player a way to know
* that loading failed.
*/
export class JsonManager {
_resourcesLoader: RuntimeGameResourcesLoader;
_resources: Map<string, ResourceData>;
export class JsonManager implements gdjs.ResourceManager {
_resourceLoader: ResourceLoader;
_loadedJsons: { [key: string]: Object } = {};
_callbacks: { [key: string]: Array<JsonManagerRequestCallback> } = {};
_loadedJsons = new gdjs.ResourceCache<Object>();
_callbacks = new gdjs.ResourceCache<Array<JsonManagerRequestCallback>>();
/**
* @param resourceDataArray The resources data of the game.
* @param resourcesLoader The resources loader of the game.
* @param resourceLoader The resources loader of the game.
*/
constructor(
resourceDataArray: ResourceData[],
resourcesLoader: RuntimeGameResourcesLoader
) {
this._resources = new Map<string, ResourceData>();
this.setResources(resourceDataArray);
this._resourcesLoader = resourcesLoader;
constructor(resourceLoader: gdjs.ResourceLoader) {
this._resourceLoader = resourceLoader;
}
/**
* Update the resources data of the game. Useful for hot-reloading, should not be used otherwise.
*
* @param resourceDataArray The resources data of the game.
*/
setResources(resourceDataArray: ResourceData[]): void {
this._resources.clear();
for (const resourceData of resourceDataArray) {
if (
resourceData.kind === 'json' ||
resourceData.kind === 'tilemap' ||
resourceData.kind === 'tileset'
) {
this._resources.set(resourceData.name, resourceData);
}
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
/**
@@ -63,32 +44,25 @@ namespace gdjs {
*
* Note that even if a JSON is already loaded, it will be reloaded (useful for hot-reloading,
* as JSON files can have been modified without the editor knowing).
*
* @param onProgress The function called after each json is loaded.
*/
async preloadJsons(
onProgress: (loadedCount: integer, totalCount: integer) => void
): Promise<integer> {
const preloadedResources = [...this._resources.values()].filter(
(resource) => !resource.disablePreload
);
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn('Unable to find json for resource "' + resourceName + '".');
return;
}
if (resource.disablePreload) {
return;
}
let loadedCount = 0;
await Promise.all(
preloadedResources.map(async (resource) => {
try {
await this.loadJsonAsync(resource.name);
} catch (error) {
logger.error(
`Error while preloading json resource ${resource.name}:`,
error
);
}
loadedCount++;
onProgress(loadedCount, this._resources.size);
})
);
return loadedCount;
try {
await this.loadJsonAsync(resource.name);
} catch (error) {
logger.error(
`Error while preloading json resource ${resource.name}:`,
error
);
}
}
loadJsonAsync(resourceName: string): Promise<Object | null> {
@@ -103,6 +77,17 @@ namespace gdjs {
});
}
private _getJsonResource = (resourceName: string): ResourceData | null => {
const resource = this._resourceLoader.getResource(resourceName);
return resource && this.getResourceKinds().includes(resource.kind)
? resource
: null;
};
async processResource(resourceName: string): Promise<void> {
// Do nothing because json are light enough to be parsed in background.
}
/**
* Request the json file from the given resource name.
* This method is asynchronous. When loaded, the `callback` is called with the error
@@ -112,7 +97,7 @@ namespace gdjs {
* @param callback The callback function called when json is loaded (or an error occurred).
*/
loadJson(resourceName: string, callback: JsonManagerRequestCallback): void {
const resource = this._resources.get(resourceName);
const resource = this._getJsonResource(resourceName);
if (!resource) {
callback(
new Error(
@@ -126,29 +111,30 @@ namespace gdjs {
}
// Don't fetch again an object that is already in memory
if (this._loadedJsons[resourceName]) {
callback(null, this._loadedJsons[resourceName]);
if (this._loadedJsons.get(resource)) {
callback(null, this._loadedJsons.get(resource));
return;
}
// Don't fetch again an object that is already being fetched.
{
const callbacks = this._callbacks[resourceName];
const callbacks = this._callbacks.get(resource);
if (callbacks) {
callbacks.push(callback);
return;
} else {
this._callbacks[resourceName] = [callback];
this._callbacks.set(resource, [callback]);
}
}
const that = this;
const xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.withCredentials = this._resourcesLoader.checkIfCredentialsRequired(
xhr.withCredentials = this._resourceLoader.checkIfCredentialsRequired(
resource.file
);
xhr.open('GET', this._resourcesLoader.getFullUrl(resource.file));
xhr.open('GET', this._resourceLoader.getFullUrl(resource.file));
xhr.onload = function () {
const callbacks = that._callbacks[resourceName];
const callbacks = that._callbacks.get(resource);
if (!callbacks) {
return;
}
@@ -161,36 +147,36 @@ namespace gdjs {
null
);
}
delete that._callbacks[resourceName];
that._callbacks.delete(resource);
return;
}
// Cache the result
that._loadedJsons[resourceName] = xhr.response;
that._loadedJsons.set(resource, xhr.response);
for (const callback of callbacks) {
callback(null, xhr.response);
}
delete that._callbacks[resourceName];
that._callbacks.delete(resource);
};
xhr.onerror = function () {
const callbacks = that._callbacks[resourceName];
const callbacks = that._callbacks.get(resource);
if (!callbacks) {
return;
}
for (const callback of callbacks) {
callback(new Error('Network error'), null);
}
delete that._callbacks[resourceName];
that._callbacks.delete(resource);
};
xhr.onabort = function () {
const callbacks = that._callbacks[resourceName];
const callbacks = that._callbacks.get(resource);
if (!callbacks) {
return;
}
for (const callback of callbacks) {
callback(new Error('Request aborted'), null);
}
delete that._callbacks[resourceName];
that._callbacks.delete(resource);
};
xhr.send();
}
@@ -201,7 +187,7 @@ namespace gdjs {
* @returns true if the content of the json resource is loaded. false otherwise.
*/
isJsonLoaded(resourceName: string): boolean {
return !!this._loadedJsons[resourceName];
return !!this._loadedJsons.getFromName(resourceName);
}
/**
@@ -212,7 +198,7 @@ namespace gdjs {
* @returns the content of the json resource, if loaded. `null` otherwise.
*/
getLoadedJson(resourceName: string): Object | null {
return this._loadedJsons[resourceName] || null;
return this._loadedJsons.getFromName(resourceName) || null;
}
}
}

View File

@@ -25,6 +25,7 @@ namespace gdjs {
class LoadingScreenPixiRenderer {
_pixiRenderer: PIXI.Renderer | null;
_loadingScreenData: LoadingScreenData;
_isFirstLayout: boolean;
_loadingScreenContainer: PIXI.Container;
_backgroundSprite: PIXI.Sprite | null = null;
@@ -40,9 +41,11 @@ namespace gdjs {
constructor(
runtimeGamePixiRenderer: gdjs.RuntimeGamePixiRenderer,
imageManager: gdjs.PixiImageManager,
loadingScreenData: LoadingScreenData
loadingScreenData: LoadingScreenData,
isFirstScene: boolean
) {
this._loadingScreenData = loadingScreenData;
this._isFirstLayout = isFirstScene;
this._loadingScreenContainer = new PIXI.Container();
this._pixiRenderer = runtimeGamePixiRenderer.getPIXIRenderer();
if (!this._pixiRenderer) {
@@ -55,7 +58,10 @@ namespace gdjs {
const backgroundTexture = imageManager.getOrLoadPIXITexture(
loadingScreenData.backgroundImageResourceName
);
if (backgroundTexture !== imageManager.getInvalidPIXITexture()) {
if (
backgroundTexture !== imageManager.getInvalidPIXITexture() &&
isFirstScene
) {
this._backgroundSprite = PIXI.Sprite.from(backgroundTexture);
this._backgroundSprite.alpha = 0;
this._backgroundSprite.anchor.x = 0.5;
@@ -63,7 +69,7 @@ namespace gdjs {
this._loadingScreenContainer.addChild(this._backgroundSprite);
}
if (loadingScreenData.showGDevelopSplash) {
if (loadingScreenData.showGDevelopSplash && isFirstScene) {
this._gdevelopLogoSprite = PIXI.Sprite.from(gdjs.gdevelopLogo);
this._gdevelopLogoSprite.alpha = 0;
this._gdevelopLogoSprite.anchor.x = 0.5;
@@ -137,6 +143,22 @@ namespace gdjs {
requestAnimationFrame(() => this._render(performance.now()));
}
this._renderIfNeeded(timeInMs);
}
renderIfNeeded(): boolean {
return this._renderIfNeeded(performance.now());
}
private _renderIfNeeded(timeInMs: float): boolean {
if (timeInMs - this._lastFrameTimeInMs < 1000 / 60) {
return false;
}
if (!this._pixiRenderer) {
return false;
}
const deltaTimeInMs = this._lastFrameTimeInMs
? timeInMs - this._lastFrameTimeInMs
: 0;
@@ -226,12 +248,14 @@ namespace gdjs {
}
this._pixiRenderer.render(this._loadingScreenContainer);
return true;
}
unload(): Promise<void> {
const totalElapsedTime = (performance.now() - this._startTimeInMs) / 1000;
const remainingTime =
this._loadingScreenData.minDuration - totalElapsedTime;
(this._isFirstLayout ? this._loadingScreenData.minDuration : 0) -
totalElapsedTime;
this.setPercent(100);
// Ensure we have shown the loading screen for at least minDuration.

View File

@@ -33,13 +33,14 @@ namespace gdjs {
return PIXI.BitmapFont.available[bitmapFontInstallKey];
};
const resourceKinds: Array<ResourceKind> = ['bitmapFont'];
/**
* PixiBitmapFontManager loads fnt/xml files (using `fetch`), from the "bitmapFont" resources of the game.
*
* It installs the "BitmapFont" with PixiJS to be used with PIXI.BitmapText.
*/
export class PixiBitmapFontManager {
_resources: Map<string, ResourceData>;
export class PixiBitmapFontManager implements gdjs.ResourceManager {
private _imageManager: gdjs.PixiImageManager;
/** Pixi.BitmapFont used, indexed by their BitmapFont name. */
@@ -52,26 +53,27 @@ namespace gdjs {
private _pixiBitmapFontsToUninstall: string[] = [];
/** Loaded fonts data, indexed by resource name. */
private _loadedFontsData: Record<string, any> = {};
private _loadedFontsData = new gdjs.ResourceCache<any>();
private _defaultSlugFontName: string | null = null;
_resourcesLoader: RuntimeGameResourcesLoader;
_resourceLoader: gdjs.ResourceLoader;
/**
* @param resourceDataArray The resources data of the game.
* @param resourcesLoader The resources loader of the game.
* @param resourceLoader The resources loader of the game.
* @param imageManager The image manager to be used to get textures used by fonts.
*/
constructor(
resourceDataArray: ResourceData[],
resourcesLoader: RuntimeGameResourcesLoader,
resourceLoader: gdjs.ResourceLoader,
imageManager: gdjs.PixiImageManager
) {
this._resources = new Map<string, ResourceData>();
this.setResources(resourceDataArray);
this._imageManager = imageManager;
this._resourcesLoader = resourcesLoader;
this._resourceLoader = resourceLoader;
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
/**
@@ -108,19 +110,6 @@ namespace gdjs {
return defaultBitmapFont;
}
/**
* Update the resources data of the game. Useful for hot-reloading, should not be used otherwise.
* @param resourceDataArray The resources data of the game.
*/
setResources(resourceDataArray: ResourceData[]): void {
this._resources.clear();
for (const resourceData of resourceDataArray) {
if (resourceData.kind === 'bitmapFont') {
this._resources.set(resourceData.name, resourceData);
}
}
}
/**
* Called to specify that the bitmap font with the specified key is used by an object
* (i.e: this is reference counting).
@@ -219,7 +208,9 @@ namespace gdjs {
// The Bitmap Font is not loaded, load it in memory.
// First get the font data:
const fontData = this._loadedFontsData[bitmapFontResourceName];
const fontData = this._loadedFontsData.getFromName(
bitmapFontResourceName
);
if (!fontData) {
logger.warn(
'Could not find Bitmap Font for resource named "' +
@@ -253,49 +244,50 @@ namespace gdjs {
}
}
async processResource(resourceName: string): Promise<void> {
// Do nothing because fonts are light enough to be parsed in background.
}
/**
* Load the "bitmapFont" resources of the game, so that they are ready
* to be used when `obtainBitmapFont` is called.
*/
async loadBitmapFontData(
onProgress: (count: integer, total: integer) => void
): Promise<integer> {
const preloadedResources = [...this._resources.values()].filter(
(resource) => !resource.disablePreload
);
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find bitmap font for resource "' + resourceName + '".'
);
return;
}
if (this._loadedFontsData.get(resource)) {
return;
}
let loadedCount = 0;
await Promise.all(
preloadedResources.map(async (bitmapFontResource) => {
try {
const response = await fetch(
this._resourcesLoader.getFullUrl(bitmapFontResource.file),
{
credentials: this._resourcesLoader.checkIfCredentialsRequired(
bitmapFontResource.file
)
? // Any resource stored on the GDevelop Cloud buckets needs the "credentials" of the user,
// i.e: its gdevelop.io cookie, to be passed.
'include'
: // For other resources, use "same-origin" as done by default by fetch.
'same-origin',
}
);
const fontData = await response.text();
this._loadedFontsData[bitmapFontResource.name] = fontData;
} catch (error) {
logger.error(
"Can't fetch the bitmap font file " +
bitmapFontResource.file +
', error: ' +
error
);
try {
const response = await fetch(
this._resourceLoader.getFullUrl(resource.file),
{
credentials: this._resourceLoader.checkIfCredentialsRequired(
resource.file
)
? // Any resource stored on the GDevelop Cloud buckets needs the "credentials" of the user,
// i.e: its gdevelop.io cookie, to be passed.
'include'
: // For other resources, use "same-origin" as done by default by fetch.
'same-origin',
}
loadedCount++;
onProgress(loadedCount, preloadedResources.length);
})
);
return loadedCount;
);
const fontData = await response.text();
this._loadedFontsData.set(resource, fontData);
} catch (error) {
logger.error(
"Can't fetch the bitmap font file " +
resource.file +
', error: ' +
error
);
}
}
}

View File

@@ -34,21 +34,12 @@ namespace gdjs {
}
};
const findResourceWithNameAndKind = (
resources: Map<string, ResourceData>,
resourceName: string,
kind: ResourceKind
): ResourceData | null => {
const resource = resources.get(resourceName);
return resource && resource.kind === kind ? resource : null;
};
const resourceKinds: Array<ResourceKind> = ['image', 'video'];
/**
* PixiImageManager loads and stores textures that can be used by the Pixi.js renderers.
*/
export class PixiImageManager {
_resources: Map<string, ResourceData>;
export class PixiImageManager implements gdjs.ResourceManager {
/**
* The invalid texture is a 8x8 PNG file filled with magenta (#ff00ff), to be
* easily spotted if rendered on screen.
@@ -58,7 +49,7 @@ namespace gdjs {
/**
* Map associating a resource name to the loaded PixiJS texture.
*/
private _loadedTextures: Hashtable<PIXI.Texture<PIXI.Resource>>;
private _loadedTextures = new gdjs.ResourceCache<PIXI.Texture>();
/**
* Map associating a resource name to the loaded Three.js texture.
@@ -66,39 +57,23 @@ namespace gdjs {
private _loadedThreeTextures: Hashtable<THREE.Texture>;
private _loadedThreeMaterials: Hashtable<THREE.Material>;
private _resourcesLoader: RuntimeGameResourcesLoader;
private _resourceLoader: gdjs.ResourceLoader;
/**
* @param resources The resources data of the game.
* @param resourcesLoader The resources loader of the game.
* @param resourceLoader The resources loader of the game.
*/
constructor(
resourceDataArray: ResourceData[],
resourcesLoader: RuntimeGameResourcesLoader
) {
this._resources = new Map<string, ResourceData>();
this.setResources(resourceDataArray);
this._resourcesLoader = resourcesLoader;
constructor(resourceLoader: gdjs.ResourceLoader) {
this._resourceLoader = resourceLoader;
this._invalidTexture = PIXI.Texture.from(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVQoU2P8z/D/PwMewDgyFAAApMMX8Zi0uXAAAAAASUVORK5CYIIA'
);
this._loadedTextures = new Hashtable();
this._loadedThreeTextures = new Hashtable();
this._loadedThreeMaterials = new Hashtable();
}
/**
* Update the resources data of the game. Useful for hot-reloading, should not be used otherwise.
*
* @param resources The resources data of the game.
*/
setResources(resourceDataArray: ResourceData[]): void {
this._resources.clear();
for (const resourceData of resourceDataArray) {
if (resourceData.kind === 'image' || resourceData.kind === 'video') {
this._resources.set(resourceData.name, resourceData);
}
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
/**
@@ -108,19 +83,28 @@ namespace gdjs {
* @returns The requested texture, or a placeholder if not found.
*/
getPIXITexture(resourceName: string): PIXI.Texture {
if (this._loadedTextures.containsKey(resourceName)) {
const texture = this._loadedTextures.get(resourceName);
if (texture.valid) {
return texture;
} else {
logger.error(
'Texture for ' +
resourceName +
' is not valid anymore (or never was).'
);
}
const resource = this._getImageResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find texture for resource "' + resourceName + '".'
);
return this._invalidTexture;
}
return this._invalidTexture;
const existingTexture = this._loadedTextures.get(resource);
if (!existingTexture) {
return this._invalidTexture;
}
if (!existingTexture.valid) {
logger.error(
'Texture for ' +
resourceName +
' is not valid anymore (or never was).'
);
return this._invalidTexture;
}
return existingTexture;
}
/**
@@ -132,10 +116,18 @@ namespace gdjs {
* @returns The requested texture, or a placeholder if not valid.
*/
getOrLoadPIXITexture(resourceName: string): PIXI.Texture {
if (this._loadedTextures.containsKey(resourceName)) {
const texture = this._loadedTextures.get(resourceName);
if (texture.valid) {
return texture;
const resource = this._getImageResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find texture for resource "' + resourceName + '".'
);
return this._invalidTexture;
}
const existingTexture = this._loadedTextures.get(resource);
if (existingTexture) {
if (existingTexture.valid) {
return existingTexture;
} else {
logger.error(
'Texture for ' +
@@ -146,29 +138,15 @@ namespace gdjs {
}
}
// Texture is not loaded, load it now from the resources list.
const resource = findResourceWithNameAndKind(
this._resources,
resourceName,
'image'
);
if (!resource) {
logger.warn(
'Unable to find texture for resource "' + resourceName + '".'
);
return this._invalidTexture;
}
logger.log('Loading texture for resource "' + resourceName + '"...');
const file = resource.file;
const url = this._resourcesLoader.getFullUrl(file);
const url = this._resourceLoader.getFullUrl(file);
const texture = PIXI.Texture.from(url, {
resourceOptions: {
// Note that using `false`
// to not having `crossorigin` at all would NOT work because the browser would taint the
// loaded resource so that it can't be read/used in a canvas (it's only working for display `<img>` on screen).
crossorigin: this._resourcesLoader.checkIfCredentialsRequired(file)
crossorigin: this._resourceLoader.checkIfCredentialsRequired(file)
? 'use-credentials'
: 'anonymous',
},
@@ -185,7 +163,7 @@ namespace gdjs {
}
applyTextureSettings(texture, resource);
this._loadedTextures.put(resourceName, texture);
this._loadedTextures.set(resource, texture);
return texture;
}
@@ -197,13 +175,15 @@ namespace gdjs {
*/
getThreeTexture(resourceName: string): THREE.Texture {
const loadedThreeTexture = this._loadedThreeTextures.get(resourceName);
if (loadedThreeTexture) return loadedThreeTexture;
if (loadedThreeTexture) {
return loadedThreeTexture;
}
// Texture is not loaded, load it now from the PixiJS texture.
// TODO (3D) - optimization: don't load the PixiJS Texture if not used by PixiJS.
// TODO (3D) - optimization: Ideally we could even share the same WebGL texture.
const pixiTexture = this.getPIXITexture(resourceName);
const pixiRenderer = this._resourcesLoader._runtimeGame
const pixiRenderer = this._resourceLoader._runtimeGame
.getRenderer()
.getPIXIRenderer();
if (!pixiRenderer) throw new Error('No PIXI renderer was found.');
@@ -224,11 +204,7 @@ namespace gdjs {
threeTexture.colorSpace = THREE.SRGBColorSpace;
threeTexture.needsUpdate = true;
const resource = findResourceWithNameAndKind(
this._resources,
resourceName,
'image'
);
const resource = this._getImageResource(resourceName);
applyThreeTextureSettings(threeTexture, resource);
this._loadedThreeTextures.put(resourceName, threeTexture);
@@ -278,12 +254,31 @@ namespace gdjs {
* @param resourceName The name of the resource to get.
*/
getPIXIVideoTexture(resourceName: string) {
if (this._loadedTextures.containsKey(resourceName)) {
return this._loadedTextures.get(resourceName);
if (resourceName === '') {
return this._invalidTexture;
}
return this._invalidTexture;
const resource = this._getImageResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find video texture for resource "' + resourceName + '".'
);
return this._invalidTexture;
}
const texture = this._loadedTextures.get(resource);
if (!texture) {
return this._invalidTexture;
}
return texture;
}
private _getImageResource = (resourceName: string): ResourceData | null => {
const resource = this._resourceLoader.getResource(resourceName);
return resource && this.getResourceKinds().includes(resource.kind)
? resource
: null;
};
/**
* Return a PIXI texture which can be used as a placeholder when no
* suitable texture can be found.
@@ -292,87 +287,95 @@ namespace gdjs {
return this._invalidTexture;
}
/**
* Load the specified resources, so that textures are loaded and can then be
* used by calling `getPIXITexture`.
*/
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find texture for resource "' + resourceName + '".'
);
return;
}
await this._loadTexture(resource);
}
async processResource(resourceName: string): Promise<void> {
// Do nothing because images are light enough to be parsed in background.
}
/**
* Load the specified resources, so that textures are loaded and can then be
* used by calling `getPIXITexture`.
* @param onProgress Callback called each time a new file is loaded.
*/
async loadTextures(
onProgress: (loadingCount: integer, totalCount: integer) => void
): Promise<integer> {
let loadedCount = 0;
await Promise.all(
[...this._resources.values()].map(async (resource) => {
try {
if (resource.kind === 'video') {
// For videos, we want to preload them so they are available as soon as we want to use them.
// We cannot use Pixi.assets.load() as it does not allow passing options (autoplay) to the resource loader.
// Pixi.Texture.from() does not return a promise, so we need to ensure we look at the 'loaded' event of the baseTexture,
// to continue, otherwise if we try to play the video too soon (at the beginning of scene for instance),
// it will fail.
await new Promise<void>((resolve, reject) => {
const texture = PIXI.Texture.from(
this._resourcesLoader.getFullUrl(resource.file),
{
resourceOptions: {
crossorigin: this._resourcesLoader.checkIfCredentialsRequired(
resource.file
)
? 'use-credentials'
: 'anonymous',
autoPlay: false,
},
}
).on('error', (error) => {
reject(error);
});
async _loadTexture(resource: ResourceData): Promise<void> {
if (this._loadedTextures.get(resource)) {
return;
}
try {
if (resource.kind === 'video') {
// For videos, we want to preload them so they are available as soon as we want to use them.
// We cannot use Pixi.assets.load() as it does not allow passing options (autoplay) to the resource loader.
// Pixi.Texture.from() does not return a promise, so we need to ensure we look at the 'loaded' event of the baseTexture,
// to continue, otherwise if we try to play the video too soon (at the beginning of scene for instance),
// it will fail.
await new Promise<void>((resolve, reject) => {
const texture = PIXI.Texture.from(
this._resourceLoader.getFullUrl(resource.file),
{
resourceOptions: {
crossorigin: this._resourceLoader.checkIfCredentialsRequired(
resource.file
)
? 'use-credentials'
: 'anonymous',
autoPlay: false,
},
}
).on('error', (error) => {
reject(error);
});
const baseTexture = texture.baseTexture;
const baseTexture = texture.baseTexture;
baseTexture
.on('loaded', () => {
this._loadedTextures.put(resource.name, texture);
applyTextureSettings(texture, resource);
resolve();
})
.on('error', (error) => {
reject(error);
});
});
} else {
// If the file has no extension, PIXI.assets.load cannot find
// an adequate load parser and does not load the file although
// we would like to force it to load (we are confident it's an image).
// TODO: When PIXI v8+ is used, PIXI.Assets.load can be used because
// loadParser can be forced in PIXI.Assets.load
// (see https://github.com/pixijs/pixijs/blob/71ed56c569ebc6b53da19e3c49258a0a84892101/packages/assets/src/loader/Loader.ts#L68)
const loadedTexture = PIXI.Texture.from(
this._resourcesLoader.getFullUrl(resource.file),
{
resourceOptions: {
autoLoad: false,
crossorigin: this._resourcesLoader.checkIfCredentialsRequired(
resource.file
)
? 'use-credentials'
: 'anonymous',
},
}
);
await loadedTexture.baseTexture.resource.load();
this._loadedTextures.put(resource.name, loadedTexture);
// TODO What if 2 assets share the same file with different settings?
applyTextureSettings(loadedTexture, resource);
baseTexture.on('loaded', () => {
this._loadedTextures.set(resource, texture);
applyTextureSettings(texture, resource);
resolve();
});
});
} else {
// If the file has no extension, PIXI.assets.load cannot find
// an adequate load parser and does not load the file although
// we would like to force it to load (we are confident it's an image).
// TODO: When PIXI v8+ is used, PIXI.Assets.load can be used because
// loadParser can be forced in PIXI.Assets.load
// (see https://github.com/pixijs/pixijs/blob/71ed56c569ebc6b53da19e3c49258a0a84892101/packages/assets/src/loader/Loader.ts#L68)
const loadedTexture = PIXI.Texture.from(
this._resourceLoader.getFullUrl(resource.file),
{
resourceOptions: {
autoLoad: false,
crossorigin: this._resourceLoader.checkIfCredentialsRequired(
resource.file
)
? 'use-credentials'
: 'anonymous',
},
}
} catch (error) {
logFileLoadingError(resource.file, error);
}
loadedCount++;
onProgress(loadedCount, this._resources.size);
})
);
return loadedCount;
);
await loadedTexture.baseTexture.resource.load();
this._loadedTextures.set(resource, loadedTexture);
// TODO What if 2 assets share the same file with different settings?
applyTextureSettings(loadedTexture, resource);
}
} catch (error) {
logFileLoadingError(resource.file, error);
}
}
}

View File

@@ -184,15 +184,18 @@ namespace gdjs {
return this._sprite.texture.frame.height;
}
static getAnimationFrame(imageManager, imageName) {
static getAnimationFrame(
imageManager: gdjs.PixiImageManager,
imageName: string
) {
return imageManager.getPIXITexture(imageName);
}
static getAnimationFrameWidth(pixiTexture) {
static getAnimationFrameWidth(pixiTexture: PIXI.Texture) {
return pixiTexture.width;
}
static getAnimationFrameHeight(pixiTexture) {
static getAnimationFrameHeight(pixiTexture: PIXI.Texture) {
return pixiTexture.height;
}
}

View File

@@ -6,6 +6,9 @@
namespace gdjs {
const logger = new gdjs.Logger('Game manager');
const sleep = (ms: float) =>
new Promise((resolve) => setTimeout(resolve, ms));
/** Identify a script file, with its content hash (useful for hot-reloading). */
export type RuntimeGameOptionsScriptFile = {
/** The path for this script file. */
@@ -14,6 +17,9 @@ namespace gdjs {
hash: number;
};
const getGlobalResourceNames = (projectData: ProjectData): Array<string> =>
projectData.usedResources.map((resource) => resource.name);
/** Options given to the game at startup. */
export type RuntimeGameOptions = {
/** if true, force fullscreen. */
@@ -63,86 +69,15 @@ namespace gdjs {
environment?: 'dev';
};
const addSearchParameterToUrl = (
url: string,
urlEncodedParameterName: string,
urlEncodedValue: string
) => {
if (url.startsWith('data:') || url.startsWith('blob:')) {
// blob/data protocol does not support search parameters, which are useless anyway.
return url;
}
const separator = url.indexOf('?') === -1 ? '?' : '&';
return url + separator + urlEncodedParameterName + '=' + urlEncodedValue;
};
const checkIfIsGDevelopCloudBucketUrl = (url: string): boolean => {
return (
url.startsWith('https://project-resources.gdevelop.io/') ||
url.startsWith('https://project-resources-dev.gdevelop.io/')
);
};
/**
* Gives helper methods used when resources are loaded from an URL.
*/
export class RuntimeGameResourcesLoader {
_runtimeGame: RuntimeGame;
constructor(runtimeGame: RuntimeGame) {
this._runtimeGame = runtimeGame;
}
/**
* Complete the given URL with any specific parameter required to access
* the resource (this can be for example a token needed to access the resource).
*/
getFullUrl(url: string) {
const { gdevelopResourceToken } = this._runtimeGame._options;
if (!gdevelopResourceToken) return url;
if (!checkIfIsGDevelopCloudBucketUrl(url)) return url;
return addSearchParameterToUrl(
url,
'gd_resource_token',
encodeURIComponent(gdevelopResourceToken)
);
}
/**
* Return true if the specified URL must be loaded with cookies ("credentials")
* sent to grant access to them.
*/
checkIfCredentialsRequired(url: string) {
if (this._runtimeGame._options.gdevelopResourceToken) return false;
// Any resource stored on the GDevelop Cloud buckets needs the "credentials" of the user,
// i.e: its gdevelop.io cookie, to be passed.
// Note that this is only useful during previews.
if (checkIfIsGDevelopCloudBucketUrl(url)) return true;
// For other resources, use the default way of loading resources ("anonymous" or "same-site").
return false;
}
}
/**
* Represents a game being played.
*/
export class RuntimeGame {
_resourcesLoader: RuntimeGameResourcesLoader;
_resourcesLoader: gdjs.ResourceLoader;
_variables: VariablesContainer;
_data: ProjectData;
_eventsBasedObjectDatas: Map<String, EventsBasedObjectData>;
_imageManager: ImageManager;
_soundManager: SoundManager;
_fontManager: FontManager;
_jsonManager: JsonManager;
_model3DManager: Model3DManager;
_effectsManager: EffectsManager;
_bitmapFontManager: BitmapFontManager;
_maxFPS: integer;
_minFPS: integer;
_gameResolutionWidth: integer;
@@ -210,33 +145,12 @@ namespace gdjs {
this._options = options || {};
this._variables = new gdjs.VariablesContainer(data.variables);
this._data = data;
this._resourcesLoader = new gdjs.RuntimeGameResourcesLoader(this);
const resources = this._data.resources.resources;
this._imageManager = new gdjs.ImageManager(
resources,
this._resourcesLoader
);
this._soundManager = new gdjs.SoundManager(
resources,
this._resourcesLoader
);
this._fontManager = new gdjs.FontManager(
resources,
this._resourcesLoader
);
this._jsonManager = new gdjs.JsonManager(
resources,
this._resourcesLoader
);
this._bitmapFontManager = new gdjs.BitmapFontManager(
resources,
this._resourcesLoader,
this._imageManager
);
this._model3DManager = new gdjs.Model3DManager(
resources,
this._resourcesLoader
this._resourcesLoader = new gdjs.ResourceLoader(
this,
data.resources.resources,
getGlobalResourceNames(data),
data.layouts
);
this._effectsManager = new gdjs.EffectsManager();
this._maxFPS = this._data.properties.maxFPS;
@@ -315,12 +229,11 @@ namespace gdjs {
*/
setProjectData(projectData: ProjectData): void {
this._data = projectData;
this._imageManager.setResources(this._data.resources.resources);
this._soundManager.setResources(this._data.resources.resources);
this._fontManager.setResources(this._data.resources.resources);
this._jsonManager.setResources(this._data.resources.resources);
this._bitmapFontManager.setResources(this._data.resources.resources);
this._model3DManager.setResources(this._data.resources.resources);
this._resourcesLoader.setResources(
projectData.resources.resources,
getGlobalResourceNames(projectData),
projectData.layouts
);
}
/**
@@ -348,7 +261,7 @@ namespace gdjs {
* @return The sound manager.
*/
getSoundManager(): gdjs.HowlerSoundManager {
return this._soundManager;
return this._resourcesLoader.getSoundManager();
}
/**
@@ -356,7 +269,7 @@ namespace gdjs {
* @return The image manager.
*/
getImageManager(): gdjs.PixiImageManager {
return this._imageManager;
return this._resourcesLoader.getImageManager();
}
/**
@@ -364,7 +277,7 @@ namespace gdjs {
* @return The font manager.
*/
getFontManager(): gdjs.FontFaceObserverFontManager {
return this._fontManager;
return this._resourcesLoader.getFontManager();
}
/**
@@ -372,8 +285,25 @@ namespace gdjs {
* @return The bitmap font manager.
*/
getBitmapFontManager(): gdjs.BitmapFontManager {
// @ts-ignore
return this._bitmapFontManager;
return this._resourcesLoader.getBitmapFontManager();
}
/**
* Get the JSON manager of the game, used to load JSON from game
* resources.
* @return The json manager for the game
*/
getJsonManager(): gdjs.JsonManager {
return this._resourcesLoader.getJsonManager();
}
/**
* Get the 3D model manager of the game, used to load 3D model from game
* resources.
* @return The 3D model manager for the game
*/
getModel3DManager(): gdjs.Model3DManager {
return this._resourcesLoader.getModel3DManager();
}
/**
@@ -385,24 +315,6 @@ namespace gdjs {
return this._inputManager;
}
/**
* Get the JSON manager of the game, used to load JSON from game
* resources.
* @return The json manager for the game
*/
getJsonManager(): gdjs.JsonManager {
return this._jsonManager;
}
/**
* Get the 3D model manager of the game, used to load 3D model from game
* resources.
* @return The 3D model manager for the game
*/
getModel3DManager(): gdjs.Model3DManager {
return this._model3DManager;
}
/**
* Get the effects manager of the game, which allows to manage
* effects on runtime objects or runtime layers.
@@ -681,54 +593,151 @@ namespace gdjs {
}
/**
* Load all assets, displaying progress in renderer.
* Preload a scene assets as soon as possible in background.
*/
prioritizeLoadingOfScene(sceneName: string) {
// Don't await the scene assets to be loaded.
this._resourcesLoader.loadSceneResources(sceneName);
}
/**
* @return The progress of assets loading in background for a scene
* (between 0 and 1).
*/
getSceneLoadingProgress(sceneName: string): number {
return this._resourcesLoader.getSceneLoadingProgress(sceneName);
}
/**
* @returns true when all the resources of the given scene are loaded
* (but maybe not parsed).
*/
areSceneAssetsLoaded(sceneName: string): boolean {
return this._resourcesLoader.areSceneAssetsLoaded(sceneName);
}
/**
* @returns true when all the resources of the given scene are loaded and
* parsed.
*/
areSceneAssetsReady(sceneName: string): boolean {
return this._resourcesLoader.areSceneAssetsReady(sceneName);
}
/**
* Load all assets needed to display the 1st scene, displaying progress in
* renderer.
*/
loadAllAssets(
callback: () => void,
progressCallback?: (progress: float) => void
) {
this.loadAllAssetsAsync(progressCallback).then(callback);
this.loadFirstAssetsAndStartBackgroundLoading(
this._getFirstSceneName(),
progressCallback
).then(callback);
}
/**
* Load all assets, displaying progress in renderer.
* Load all assets needed to display the 1st scene, displaying progress in
* renderer.
*
* When a game is hot-reload, this method can be called with the current
* scene.
*/
loadAllAssetsAsync = async (
async loadFirstAssetsAndStartBackgroundLoading(
firstSceneName: string,
progressCallback?: (progress: float) => void
) => {
): Promise<void> {
try {
const loadingScreen = new gdjs.LoadingScreenRenderer(
this.getRenderer(),
this._imageManager,
this._data.properties.loadingScreen
await this._loadAssetsWithLoadingScreen(
/* isFirstScene = */ true,
async (onProgress) => {
// TODO Is a setting needed?
if (false) {
await this._resourcesLoader.loadAllResources(onProgress);
} else {
await this._resourcesLoader.loadGlobalAndFirstSceneResources(
firstSceneName,
onProgress
);
// Don't await as it must not block the first scene from starting.
this._resourcesLoader.loadAllSceneInBackground();
}
},
progressCallback
);
const allAssetsTotal = this._data.resources.resources.length;
let loadedAssets = 0;
const onProgress = (count: integer, total: integer) => {
const percent = Math.floor(
(100 * (loadedAssets + count)) / allAssetsTotal
);
loadingScreen.setPercent(percent);
if (progressCallback) {
progressCallback(percent);
}
};
loadedAssets += await this._imageManager.loadTextures(onProgress);
loadedAssets += await this._soundManager.preloadAudio(onProgress);
loadedAssets += await this._fontManager.loadFonts(onProgress);
loadedAssets += await this._jsonManager.preloadJsons(onProgress);
loadedAssets += await this._model3DManager.loadModels(onProgress);
await this._bitmapFontManager.loadBitmapFontData(onProgress);
await loadingScreen.unload();
// TODO This is probably not necessary in case of hot reload.
await gdjs.getAllAsynchronouslyLoadingLibraryPromise();
} catch (e) {
if (this._debuggerClient) this._debuggerClient.onUncaughtException(e);
throw e;
}
};
}
/**
* Load all assets for a given scene, displaying progress in renderer.
*/
async loadSceneAssets(
sceneName: string,
progressCallback?: (progress: float) => void
): Promise<void> {
await this._loadAssetsWithLoadingScreen(
/* isFirstLayout = */ false,
async (onProgress) => {
await this._resourcesLoader.loadAndProcessSceneResources(
sceneName,
onProgress
);
},
progressCallback
);
}
/**
* Load assets, displaying progress in renderer.
*/
private async _loadAssetsWithLoadingScreen(
isFirstScene: boolean,
loadAssets: (
onProgress: (count: integer, total: integer) => Promise<void>
) => Promise<void>,
progressCallback?: (progress: float) => void
): Promise<void> {
this.pause(true);
const loadingScreen = new gdjs.LoadingScreenRenderer(
this.getRenderer(),
this._resourcesLoader.getImageManager(),
this._data.properties.loadingScreen,
isFirstScene
);
const onProgress = async (count: integer, total: integer) => {
const percent = Math.floor((100 * count) / total);
loadingScreen.setPercent(percent);
if (progressCallback) {
progressCallback(percent);
}
const hasRendered = loadingScreen.renderIfNeeded();
if (hasRendered) {
// Give a chance to draw calls from the renderer to be handled.
await sleep(1);
}
};
await loadAssets(onProgress);
await loadingScreen.unload();
this.pause(false);
}
private _getFirstSceneName(): string {
const firstSceneName = this._data.firstLayout;
return this.hasScene(firstSceneName)
? firstSceneName
: // @ts-ignore - no risk of null object.
this.getSceneData().name;
}
/**
* Start the game loop, to be called once assets are loaded.
@@ -742,12 +751,8 @@ namespace gdjs {
this._forceGameResolutionUpdate();
// Load the first scene
const firstSceneName = this._data.firstLayout;
this._sceneStack.push(
this.hasScene(firstSceneName)
? firstSceneName
: // @ts-ignore - no risk of null object.
this.getSceneData().name,
this._getFirstSceneName(),
this._injectExternalLayout
);
this._watermark.displayAtStartup();

View File

@@ -8,6 +8,7 @@ namespace gdjs {
_runtimeGame: gdjs.RuntimeGame;
_stack: gdjs.RuntimeScene[] = [];
_wasFirstSceneLoaded: boolean = false;
_isNextLayoutLoading: boolean = false;
/**
* @param runtimeGame The runtime game that is using the scene stack
@@ -31,7 +32,7 @@ namespace gdjs {
}
step(elapsedTime: float): boolean {
if (this._stack.length === 0) {
if (this._isNextLayoutLoading || this._stack.length === 0) {
return false;
}
const currentScene = this._stack[this._stack.length - 1];
@@ -91,13 +92,34 @@ namespace gdjs {
* Pause the scene currently being played and start the new scene that is specified.
* If `externalLayoutName` is set, also instantiate the objects from this external layout.
*/
push(newSceneName: string, externalLayoutName?: string): gdjs.RuntimeScene {
push(
newSceneName: string,
externalLayoutName?: string
): gdjs.RuntimeScene | null {
// Tell the scene it's being paused
const currentScene = this._stack[this._stack.length - 1];
if (currentScene) {
currentScene.onPause();
}
// Avoid a risk of displaying an intermediate loading screen
// during 1 frame.
if (this._runtimeGame.areSceneAssetsReady(newSceneName)) {
return this._loadNewScene(newSceneName, externalLayoutName);
}
this._isNextLayoutLoading = true;
this._runtimeGame.loadSceneAssets(newSceneName).then(() => {
this._loadNewScene(newSceneName);
this._isNextLayoutLoading = false;
});
return null;
}
private _loadNewScene(
newSceneName: string,
externalLayoutName?: string
): gdjs.RuntimeScene {
// Load the new one
const newScene = new gdjs.RuntimeScene(this._runtimeGame);
newScene.loadFromScene(this._runtimeGame.getSceneData(newSceneName));
@@ -127,7 +149,7 @@ namespace gdjs {
* Start the specified scene, replacing the one currently being played.
* If `clear` is set to true, all running scenes are also removed from the stack of scenes.
*/
replace(newSceneName: string, clear?: boolean): gdjs.RuntimeScene {
replace(newSceneName: string, clear?: boolean): gdjs.RuntimeScene | null {
if (!!clear) {
// Unload all the scenes
while (this._stack.length !== 0) {

View File

@@ -12,6 +12,7 @@ declare interface ProjectData {
gdVersion: GdVersionData;
properties: ProjectPropertiesData;
resources: ResourcesData;
usedResources: ResourceReference[];
objects: ObjectData[];
variables: RootVariableData[];
layouts: LayoutData[];
@@ -83,6 +84,7 @@ declare interface LayoutData {
objects: ObjectData[];
layers: LayerData[];
behaviorsSharedData: BehaviorSharedData[];
usedResources: ResourceReference[];
}
declare interface EventsFunctionsExtensionData {
@@ -264,6 +266,10 @@ declare interface ResourceData {
preloadInCache?: boolean;
}
declare interface ResourceReference {
name: string;
}
declare type ResourceKind =
| 'audio'
| 'image'

View File

@@ -49,6 +49,8 @@ module.exports = function (config) {
'./newIDE/app/resources/GDJS/Runtime/fontfaceobserver-font-manager/fontfaceobserver-font-manager.js',
'./newIDE/app/resources/GDJS/Runtime/jsonmanager.js',
'./newIDE/app/resources/GDJS/Runtime/Model3DManager.js',
'./newIDE/app/resources/GDJS/Runtime/ResourceLoader.js',
'./newIDE/app/resources/GDJS/Runtime/ResourceCache.js',
'./newIDE/app/resources/GDJS/Runtime/timemanager.js',
'./newIDE/app/resources/GDJS/Runtime/polygon.js',
'./newIDE/app/resources/GDJS/Runtime/runtimeobject.js',

View File

@@ -64,6 +64,7 @@ gdjs.getPixiRuntimeGame = (settings) => {
externalLayouts: [],
resources: (settings && settings.resources) || { resources: [] },
eventsFunctionsExtensions: [],
usedResources: [],
});
return runtimeGame;

View File

@@ -63,7 +63,23 @@ gdjs.getPixiRuntimeGameWithAssets = () => {
revision: 0,
},
objects: [],
layouts: [],
layouts: [
{
r: 0,
v: 0,
b: 0,
mangledName: '',
name: '',
objects: [],
layers: [],
instances: [],
behaviorsSharedData: [],
stopSoundsOnStartup: false,
title: '',
variables: [],
usedResources: [],
},
],
externalLayouts: [],
resources: {
resources: [
@@ -76,6 +92,7 @@ gdjs.getPixiRuntimeGameWithAssets = () => {
},
],
},
usedResources: [{ name: 'base/tests-utils/assets/64x64.jpg' }],
// Used in CustomRuntimeObjects.js
eventsFunctionsExtensions: [
{

View File

@@ -0,0 +1,40 @@
/*
* GDevelop JS Platform
* Copyright 2013-2023 Florian Rival (Florian.Rival@gmail.com). All rights reserved.
* This project is released under the MIT License.
*/
// implements gdjs.ResourceManager
gdjs.MockedResourceManager = class MockedResourceManager {
loadResourceCallbacks = new Map();
loadResource(resourceName) {
const that = this;
return new Promise((resolve, reject) => {
that.loadResourceCallbacks.set(resourceName, resolve);
});
}
async processResource(resourceName) {}
/**
* @param {string} resourceName
* @returns {boolean}
*/
isResourceDownloadPending(resourceName) {
return this.loadResourceCallbacks.has(resourceName);
}
/**
* @param {string} resourceName
*/
markPendingResourcesAsLoaded(resourceName) {
const loadResourceCallback = this.loadResourceCallbacks.get(resourceName);
loadResourceCallback();
this.loadResourceCallbacks.delete(resourceName);
}
getResourceKinds() {
return ['fake-heavy-resource'];
}
}

View File

@@ -106,6 +106,7 @@ describe('gdjs.EffectsManager', () => {
behaviorsSharedData: [],
objects: [],
instances: [],
usedResources: [],
});
const runtimeLayer = runtimeScene.getLayer('');

View File

@@ -46,6 +46,7 @@ describe('gdjs.RuntimeScene integration tests', function () {
},
],
instances: [],
usedResources: [],
});
const object = runtimeScene.createObject('Object1');

View File

@@ -3,47 +3,62 @@
/**
* Tests for gdjs.SceneStack.
*/
describe('gdjs.SceneStack', function () {
const runtimeGame = gdjs.getPixiRuntimeGame({
layouts: [
{
r: 0,
v: 0,
b: 0,
mangledName: 'Scene2',
name: 'Scene 1',
objects: [],
layers: [],
instances: [],
behaviorsSharedData: [],
stopSoundsOnStartup: false,
title: '',
variables: [],
},
{
r: 0,
v: 0,
b: 0,
mangledName: 'Scene2',
name: 'Scene 2',
objects: [],
layers: [],
instances: [],
behaviorsSharedData: [],
stopSoundsOnStartup: false,
title: '',
variables: [],
},
],
});
var sceneStack = runtimeGame._sceneStack;
describe('gdjs.SceneStack', () => {
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
const createSene = (name, usedResources) => {
return {
r: 0,
v: 0,
b: 0,
mangledName: name,
name,
objects: [],
layers: [],
instances: [],
behaviorsSharedData: [],
stopSoundsOnStartup: false,
title: '',
variables: [],
usedResources,
};
};
const gameSettings = {
layouts: [
createSene('Scene 1', []),
createSene('Scene 2', [{ name: 'base/tests-utils/assets/64x64.jpg' }]),
],
resources: {
resources: [
{
kind: 'image',
name: 'base/tests-utils/assets/64x64.jpg',
metadata: '',
file: 'base/tests-utils/assets/64x64.jpg',
userAdded: true,
},
],
},
};
it('should support pushing, replacing and popping scenes', async () => {
//@ts-ignore
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettings);
let sceneStack = runtimeGame._sceneStack;
// Async asset loading is not tested here.
await runtimeGame._resourcesLoader.loadAllResources(() => {});
it('should support pushing, replacing and popping scenes', function () {
// Set up some scene callbacks.
/** @type gdjs.RuntimeScene | null */
let firstLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastUnloadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastPausedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastResumedScene = null;
const onFirstRuntimeSceneLoaded = (runtimeScene) => {
@@ -74,22 +89,22 @@ describe('gdjs.SceneStack', function () {
expect(firstLoadedScene).to.be(null);
expect(sceneStack.wasFirstSceneLoaded()).to.be(false);
var scene1 = sceneStack.push('Scene 1');
let scene1 = sceneStack.push('Scene 1');
expect(lastLoadedScene).to.be(scene1);
expect(firstLoadedScene).to.be(scene1);
expect(sceneStack.wasFirstSceneLoaded()).to.be(true);
var scene2 = sceneStack.push('Scene 2');
let scene2 = sceneStack.push('Scene 2');
expect(lastPausedScene).to.be(scene1);
expect(lastLoadedScene).to.be(scene2);
expect(firstLoadedScene).to.be(scene1); // Not changed
var scene3 = sceneStack.push('Scene 1');
let scene3 = sceneStack.push('Scene 1');
expect(lastPausedScene).to.be(scene2);
expect(lastLoadedScene).to.be(scene3);
expect(firstLoadedScene).to.be(scene1); // Not changed
var scene4 = sceneStack.replace('Scene 1');
let scene4 = sceneStack.replace('Scene 1');
expect(lastPausedScene).to.be(scene2); // Not changed
expect(lastUnloadedScene).to.be(scene3);
expect(lastLoadedScene).to.be(scene4);
@@ -107,7 +122,7 @@ describe('gdjs.SceneStack', function () {
expect(lastPausedScene).to.be(scene2); // Not changed
expect(firstLoadedScene).to.be(scene1); // Not changed
var scene5 = sceneStack.replace('Scene 2', true);
let scene5 = sceneStack.replace('Scene 2', true);
expect(lastLoadedScene).to.be(scene5);
expect(lastUnloadedScene).to.be(scene1);
expect(lastPausedScene).to.be(scene2); // Not changed
@@ -130,4 +145,390 @@ describe('gdjs.SceneStack', function () {
gdjs._unregisterCallback(onRuntimeScenePaused);
gdjs._unregisterCallback(onRuntimeSceneResumed);
});
const gameSettingsWithHeavyResource = {
layouts: [
createSene('Scene 1', [{ name: 'fake-heavy-resource1.png' }]),
createSene('Scene 2', [{ name: 'fake-heavy-resource2.png' }]),
createSene('Scene 3', [{ name: 'fake-heavy-resource3.png' }]),
createSene('Scene 4', [{ name: 'fake-heavy-resource4.png' }]),
],
resources: {
resources: [
{
kind: 'fake-heavy-resource',
name: 'fake-heavy-resource1.png',
metadata: '',
file: 'fake-heavy-resource1.png',
userAdded: true,
},
{
kind: 'fake-heavy-resource',
name: 'fake-heavy-resource2.png',
metadata: '',
file: 'fake-heavy-resource2.png',
userAdded: true,
},
{
kind: 'fake-heavy-resource',
name: 'fake-heavy-resource3.png',
metadata: '',
file: 'fake-heavy-resource3.png',
userAdded: true,
},
{
kind: 'fake-heavy-resource',
name: 'fake-heavy-resource4.png',
metadata: '',
file: 'fake-heavy-resource4.png',
userAdded: true,
},
],
},
};
it('can start a layout when all its assets are already downloaded', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
//@ts-ignore
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithHeavyResource);
runtimeGame._resourcesLoader._resourceManagersMap.set(
//@ts-ignore
'fake-heavy-resource',
mockedResourceManager
);
let sceneStack = runtimeGame._sceneStack;
// Set up some scene callbacks.
/** @type gdjs.RuntimeScene | null */
let firstLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastPausedScene = null;
const onFirstRuntimeSceneLoaded = (runtimeScene) => {
firstLoadedScene = runtimeScene;
};
const onRuntimeSceneLoaded = (runtimeScene) => {
lastLoadedScene = runtimeScene;
};
const onRuntimeScenePaused = (runtimeScene) => {
lastPausedScene = runtimeScene;
};
gdjs.registerFirstRuntimeSceneLoadedCallback(onFirstRuntimeSceneLoaded);
gdjs.registerRuntimeSceneLoadedCallback(onRuntimeSceneLoaded);
gdjs.registerRuntimeScenePausedCallback(onRuntimeScenePaused);
// The test do not await because test test will unblock
// `loadFirstAssetsAsync` with `markPendingResourcesAsLoaded`.
runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene 1');
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource1.png'
)
).to.be(true);
// No layout has loaded.
expect(lastLoadedScene).to.be(null);
expect(firstLoadedScene).to.be(null);
expect(sceneStack.wasFirstSceneLoaded()).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 1')).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
// Assets of the 1st layout are downloaded before the layout is pushed.
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource1.png'
);
await delay(10);
sceneStack.push('Scene 1');
// The 1st layout is loaded
expect(lastLoadedScene).not.to.be(null);
expect(firstLoadedScene).not.to.be(null);
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(firstLoadedScene.getName()).to.be('Scene 1');
expect(sceneStack.wasFirstSceneLoaded()).to.be(true);
expect(runtimeGame.areSceneAssetsLoaded('Scene 1')).to.be(true);
expect(runtimeGame.areSceneAssetsReady('Scene 1')).to.be(true);
expect(runtimeGame.areSceneAssetsLoaded('Scene 2')).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
// "Scene 2" is loading in background.
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource2.png'
)
).to.be(true);
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
// Finish to load "Scene 2" assets.
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource2.png'
);
await delay(10);
expect(runtimeGame.areSceneAssetsLoaded('Scene 2')).to.be(true);
// The player triggers "Scene 2" to start.
sceneStack.push('Scene 2');
// "Scene 2" is loaded for the 1st time, assets are processed
// asynchronously.
await delay(10);
expect(lastPausedScene).not.to.be(null);
//@ts-ignore
expect(lastPausedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 2');
// The player triggers "Scene 1" to start.
sceneStack.push('Scene 1');
// "Scene 1" has already been shown the scene change is done synchronously.
expect(lastPausedScene).not.to.be(null);
//@ts-ignore
expect(lastPausedScene.getName()).to.be('Scene 2');
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 1');
// Remove all the global callbacks
gdjs._unregisterCallback(onFirstRuntimeSceneLoaded);
gdjs._unregisterCallback(onRuntimeSceneLoaded);
gdjs._unregisterCallback(onRuntimeScenePaused);
});
it('can start a layout while assets loading and wait them to finish', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
//@ts-ignore
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithHeavyResource);
runtimeGame._resourcesLoader._resourceManagersMap.set(
//@ts-ignore
'fake-heavy-resource',
mockedResourceManager
);
let sceneStack = runtimeGame._sceneStack;
// Set up some scene callbacks.
/** @type gdjs.RuntimeScene | null */
let firstLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastPausedScene = null;
const onFirstRuntimeSceneLoaded = (runtimeScene) => {
firstLoadedScene = runtimeScene;
};
const onRuntimeSceneLoaded = (runtimeScene) => {
lastLoadedScene = runtimeScene;
};
const onRuntimeScenePaused = (runtimeScene) => {
lastPausedScene = runtimeScene;
};
gdjs.registerFirstRuntimeSceneLoadedCallback(onFirstRuntimeSceneLoaded);
gdjs.registerRuntimeSceneLoadedCallback(onRuntimeSceneLoaded);
gdjs.registerRuntimeScenePausedCallback(onRuntimeScenePaused);
// The test do not await because test test will unblock
// `loadFirstAssetsAsync` with `markPendingResourcesAsLoaded`.
runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene 1');
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource1.png'
)
).to.be(true);
// No layout has loaded.
expect(lastLoadedScene).to.be(null);
expect(firstLoadedScene).to.be(null);
expect(sceneStack.wasFirstSceneLoaded()).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 1')).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
// Assets of the 1st layout are downloaded before the layout is pushed.
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource1.png'
);
await delay(10);
sceneStack.push('Scene 1');
// The 1st layout is loaded
expect(lastLoadedScene).not.to.be(null);
expect(firstLoadedScene).not.to.be(null);
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(firstLoadedScene.getName()).to.be('Scene 1');
expect(sceneStack.wasFirstSceneLoaded()).to.be(true);
expect(runtimeGame.areSceneAssetsReady('Scene 1')).to.be(true);
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
// The 2nd layout is loading in background.
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource2.png'
)
).to.be(true);
// The player triggers "Scene 2" to load.
let scene2 = sceneStack.push('Scene 2');
expect(scene2).to.be(null);
await delay(10);
// The 2nd layout is not loaded because its assets are still being downloaded.
//@ts-ignore
expect(lastPausedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 1');
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource2.png'
);
await delay(10);
// The 2nd layout is now loaded.
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(true);
expect(lastPausedScene).not.to.be(null);
//@ts-ignore
expect(lastPausedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 2');
// Remove all the global callbacks
gdjs._unregisterCallback(onFirstRuntimeSceneLoaded);
gdjs._unregisterCallback(onRuntimeSceneLoaded);
gdjs._unregisterCallback(onRuntimeScenePaused);
});
it.only('can start a layout which assets loading didn\'t stated yet and wait them to finish', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
//@ts-ignore
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithHeavyResource);
runtimeGame._resourcesLoader._resourceManagersMap.set(
//@ts-ignore
'fake-heavy-resource',
mockedResourceManager
);
let sceneStack = runtimeGame._sceneStack;
// Set up some scene callbacks.
/** @type gdjs.RuntimeScene | null */
let firstLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastLoadedScene = null;
/** @type gdjs.RuntimeScene | null */
let lastPausedScene = null;
const onFirstRuntimeSceneLoaded = (runtimeScene) => {
firstLoadedScene = runtimeScene;
};
const onRuntimeSceneLoaded = (runtimeScene) => {
lastLoadedScene = runtimeScene;
};
const onRuntimeScenePaused = (runtimeScene) => {
lastPausedScene = runtimeScene;
};
gdjs.registerFirstRuntimeSceneLoadedCallback(onFirstRuntimeSceneLoaded);
gdjs.registerRuntimeSceneLoadedCallback(onRuntimeSceneLoaded);
gdjs.registerRuntimeScenePausedCallback(onRuntimeScenePaused);
// The test do not await because test test will unblock
// `loadFirstAssetsAsync` with `markPendingResourcesAsLoaded`.
runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene 1');
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource1.png'
)
).to.be(true);
// No layout has loaded.
expect(lastLoadedScene).to.be(null);
expect(firstLoadedScene).to.be(null);
expect(sceneStack.wasFirstSceneLoaded()).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 1')).to.be(false);
expect(runtimeGame.areSceneAssetsReady('Scene 2')).to.be(false);
// Assets of the 1st layout are downloaded before the layout is pushed.
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource1.png'
);
await delay(10);
sceneStack.push('Scene 1');
// The 1st layout is loaded
expect(lastLoadedScene).not.to.be(null);
expect(firstLoadedScene).not.to.be(null);
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(firstLoadedScene.getName()).to.be('Scene 1');
expect(sceneStack.wasFirstSceneLoaded()).to.be(true);
expect(runtimeGame.areSceneAssetsLoaded('Scene 1')).to.be(true);
expect(runtimeGame.areSceneAssetsLoaded('Scene 2')).to.be(false);
// "Scene 2" is loaded on background but is blocked because
// 'fake-heavy-resource2.png' take a lot of time to load.
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource2.png'
)
).to.be(true);
expect(runtimeGame.areSceneAssetsLoaded('Scene 2')).to.be(false);
// The player triggers "Scene 4" to load.
let scene4 = sceneStack.push('Scene 4');
expect(scene4).to.be(null);
await delay(10);
// "Scene 4" loading doesn't start yet as assets are currently downloading
// for "Scene 2".
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource4.png'
)
).to.be(false);
expect(runtimeGame.areSceneAssetsLoaded('Scene 2')).to.be(false);
expect(runtimeGame.areSceneAssetsLoaded('Scene 4')).to.be(false);
// Finish to download "Scene 2" assets.
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource2.png'
);
await delay(10);
// "Scene 4" assets are now downloading.
expect(
mockedResourceManager.isResourceDownloadPending(
'fake-heavy-resource4.png'
)
).to.be(true);
// "Scene 4" is not loaded because its assets are still being downloading.
//@ts-ignore
expect(lastPausedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 1');
expect(runtimeGame.areSceneAssetsLoaded('Scene 2')).to.be(true);
expect(runtimeGame.areSceneAssetsLoaded('Scene 4')).to.be(false);
mockedResourceManager.markPendingResourcesAsLoaded(
'fake-heavy-resource4.png'
);
await delay(10);
// "Scene 4" is now loaded.
expect(runtimeGame.areSceneAssetsReady('Scene 4')).to.be(true);
expect(lastPausedScene).not.to.be(null);
//@ts-ignore
expect(lastPausedScene.getName()).to.be('Scene 1');
//@ts-ignore
expect(lastLoadedScene.getName()).to.be('Scene 4');
// Remove all the global callbacks
gdjs._unregisterCallback(onFirstRuntimeSceneLoaded);
gdjs._unregisterCallback(onRuntimeSceneLoaded);
gdjs._unregisterCallback(onRuntimeScenePaused);
});
});

View File

@@ -169,6 +169,12 @@ Use the THNK Framework if you seek an easier, more performant and more flexible
message: t`If the parameter is a string or a number, you probably want to use the expressions "GetArgumentAsString" or "GetArgumentAsNumber", along with the conditions "Compare two strings" or "Compare two numbers".`,
};
}
if (type === 'PrioritizeLoadingOfScene') {
return {
kind: 'info',
message: t`For most games, the default automatic loading of resources will be fine. This action should only be used when trying to avoid loading screens from appearing between scenes.`,
};
}
return null;
};