Allow to unload scene resources when a scene is exited (#7381)

* This adds two new settings, available in the Project Properties and in the Scene Properties dialog, to allow to specify the strategy for preloading resources of the scenes and unloading them. By default, a game will preload in background the resources of all scenes. It will never unload these resources (so scene switching is fast).
* You can now choose to unload the resources of a scene when the scene is left using the "Resources unloading" field. If the scene is launched again later, it will load its resources again.
* You can also choose to change the preloading to disable it for all scenes (by modifying the setting in the project properties) or enable it/disable it on a scene by scene basis. This can be useful for large or modular games where you anticipate the player to only play some scenes, or if you want to reduce the resources that needs to be loaded on a web game.
This commit is contained in:
ViktorVovk
2025-07-02 17:09:52 +03:00
committed by GitHub
parent dff99b79cb
commit 5c71a4da56
40 changed files with 19138 additions and 1936 deletions

View File

@@ -36,7 +36,7 @@ namespace gd {
gd::BehaviorsSharedData Layout::badBehaviorSharedData("", "");
Layout::Layout(const Layout &other)
Layout::Layout(const Layout& other)
: objectsContainer(gd::ObjectsContainer::SourceType::Scene) {
Init(other);
}
@@ -54,6 +54,8 @@ Layout::Layout()
backgroundColorG(209),
backgroundColorB(209),
stopSoundsOnStartup(true),
resourcesPreloading("inherit"),
resourcesUnloading("inherit"),
standardSortMethod(true),
disableInputWhenNotFocused(true),
variables(gd::VariablesContainer::SourceType::Scene),
@@ -244,6 +246,10 @@ void Layout::SerializeTo(SerializerElement& element) const {
element.SetAttribute("title", GetWindowDefaultTitle());
element.SetAttribute("standardSortMethod", standardSortMethod);
element.SetAttribute("stopSoundsOnStartup", stopSoundsOnStartup);
if (resourcesPreloading != "inherit")
element.SetAttribute("resourcesPreloading", resourcesPreloading);
if (resourcesUnloading != "inherit")
element.SetAttribute("resourcesUnloading", resourcesUnloading);
element.SetAttribute("disableInputWhenNotFocused",
disableInputWhenNotFocused);
@@ -304,6 +310,10 @@ void Layout::UnserializeFrom(gd::Project& project,
element.GetStringAttribute("title", "(No title)", "titre"));
standardSortMethod = element.GetBoolAttribute("standardSortMethod");
stopSoundsOnStartup = element.GetBoolAttribute("stopSoundsOnStartup");
resourcesPreloading =
element.GetStringAttribute("resourcesPreloading", "inherit");
resourcesUnloading =
element.GetStringAttribute("resourcesUnloading", "inherit");
disableInputWhenNotFocused =
element.GetBoolAttribute("disableInputWhenNotFocused");
@@ -391,6 +401,8 @@ void Layout::Init(const Layout& other) {
standardSortMethod = other.standardSortMethod;
title = other.title;
stopSoundsOnStartup = other.stopSoundsOnStartup;
resourcesPreloading = other.resourcesPreloading;
resourcesUnloading = other.resourcesUnloading;
disableInputWhenNotFocused = other.disableInputWhenNotFocused;
initialInstances = other.initialInstances;
layers = other.layers;

View File

@@ -349,6 +349,36 @@ class GD_CORE_API Layout {
* launched
*/
bool StopSoundsOnStartup() const { return stopSoundsOnStartup; }
/**
* Set when the scene must preload its resources: `at-startup`, `never` or
* `inherit` (default).
*/
void SetResourcesPreloading(gd::String resourcesPreloading_) {
resourcesPreloading = resourcesPreloading_;
}
/**
* Get when the scene must preload its resources: `at-startup`, `never` or
* `inherit` (default).
*/
const gd::String& GetResourcesPreloading() const {
return resourcesPreloading;
}
/**
* Set when the scene must unload its resources: `at-scene-exit`, `never` or
* `inherit` (default).
*/
void SetResourcesUnloading(gd::String resourcesUnloading_) {
resourcesUnloading = resourcesUnloading_;
}
/**
* Get when the scene must unload its resources: `at-scene-exit`, `never` or
* `inherit` (default).
*/
const gd::String& GetResourcesUnloading() const { return resourcesUnloading; }
///@}
/** \name Saving and loading
@@ -381,6 +411,10 @@ class GD_CORE_API Layout {
behaviorsSharedData; ///< Initial shared datas of behaviors
bool stopSoundsOnStartup = true; ///< True to make the scene stop all sounds at
///< startup.
gd::String
resourcesPreloading; ///< `at-startup`, `never` or `inherit` (default).
gd::String
resourcesUnloading; ///< `at-scene-exit`, `never` or `inherit` (default).
bool standardSortMethod = true; ///< True to sort objects using standard sort.
bool disableInputWhenNotFocused = true; /// If set to true, the input must be
/// disabled when the window do not have the

View File

@@ -74,7 +74,9 @@ Project::Project()
gdMinorVersion(gd::VersionWrapper::Minor()),
gdBuildVersion(gd::VersionWrapper::Build()),
variables(gd::VariablesContainer::SourceType::Global),
objectsContainer(gd::ObjectsContainer::SourceType::Global) {}
objectsContainer(gd::ObjectsContainer::SourceType::Global),
sceneResourcesPreloading("at-startup"),
sceneResourcesUnloading("never") {}
Project::~Project() {}
@@ -1166,6 +1168,13 @@ void Project::SerializeTo(SerializerElement& element) const {
else
std::cout << "ERROR: The project current platform is NULL.";
if (sceneResourcesPreloading != "at-startup") {
propElement.SetAttribute("sceneResourcesPreloading", sceneResourcesPreloading);
}
if (sceneResourcesUnloading != "never") {
propElement.SetAttribute("sceneResourcesUnloading", sceneResourcesUnloading);
}
resourcesManager.SerializeTo(element.AddChild("resources"));
objectsContainer.SerializeObjectsTo(element.AddChild("objects"));
objectsContainer.SerializeFoldersTo(element.AddChild("objectsFolderStructure"));
@@ -1307,6 +1316,9 @@ void Project::Init(const gd::Project& game) {
variables = game.GetVariables();
projectFile = game.GetProjectFile();
sceneResourcesPreloading = game.sceneResourcesPreloading;
sceneResourcesUnloading = game.sceneResourcesUnloading;
}
} // namespace gd

View File

@@ -964,6 +964,37 @@ class GD_CORE_API Project {
*/
ResourcesManager& GetResourcesManager() { return resourcesManager; }
/**
* Set when the scenes must preload their resources: `at-startup`, `never`
* (default).
*/
void SetSceneResourcesPreloading(gd::String sceneResourcesPreloading_) {
sceneResourcesPreloading = sceneResourcesPreloading_;
}
/**
* Get when the scenes must preload their resources: `at-startup`, `never`
* (default).
*/
const gd::String& GetSceneResourcesPreloading() const {
return sceneResourcesPreloading;
}
/**
* Set when the scenes must unload their resources: `at-scene-exit`, `never`
* (default).
*/
void SetSceneResourcesUnloading(gd::String sceneResourcesUnloading_) {
sceneResourcesUnloading = sceneResourcesUnloading_;
}
/**
* Get when the scenes must unload their resources: `at-scene-exit`, `never`
* (default).
*/
const gd::String& GetSceneResourcesUnloading() const {
return sceneResourcesUnloading;
}
///@}
/** \name Variable management
@@ -1121,6 +1152,10 @@ class GD_CORE_API Project {
ExtensionProperties
extensionProperties; ///< The properties of the extensions.
gd::WholeProjectDiagnosticReport wholeProjectDiagnosticReport;
gd::String sceneResourcesPreloading; ///< `at-startup` or `never`
///< (default: `at-startup`).
gd::String sceneResourcesUnloading; ///< `at-scene-exit` or `never`
///< (default: `never`).
mutable unsigned int gdMajorVersion =
0; ///< The GD major version used the last
///< time the project was saved.

View File

@@ -195,11 +195,35 @@ namespace gdjs {
}
/**
* To be called when the game is disposed.
* Clear the Spine Atlases loaded in this manager.
* Clear the Spine atlases loaded in this manager.
*/
dispose(): void {
this._loadedSpineAtlases.clear();
this._loadingSpineAtlases.clear();
}
/**
* Unload the specified list of resources:
* this clears the Spine atlases loaded in this manager.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedSpineAtlas = this._loadedSpineAtlases.get(resourceData);
if (loadedSpineAtlas) {
loadedSpineAtlas.dispose();
this._loadedSpineAtlases.delete(resourceData);
}
const loadingSpineAtlas = this._loadingSpineAtlases.get(resourceData);
if (loadingSpineAtlas) {
loadingSpineAtlas.then((atl) => atl.dispose());
this._loadingSpineAtlases.delete(resourceData);
}
});
}
}
}

View File

@@ -126,5 +126,22 @@ namespace gdjs {
dispose(): void {
this._loadedSpines.clear();
}
/**
* Unload the specified list of resources:
* this clears the Spine skeleton data loaded in this manager.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedSpine = this._loadedSpines.get(resourceData);
if (loadedSpine) {
this._loadedSpines.delete(resourceData);
}
});
}
}
}

View File

@@ -162,5 +162,29 @@ namespace gdjs {
this._invalidModel.scene.clear();
}
}
/**
* Unload the specified list of resources:
* this clears the models, resources loaded and destroy 3D models loaders in this manager.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedThreeModel = this._loadedThreeModels.get(resourceData);
if (loadedThreeModel) {
loadedThreeModel.scene.clear();
this._loadedThreeModels.delete(resourceData);
}
const downloadedArrayBuffer =
this._downloadedArrayBuffers.get(resourceData);
if (downloadedArrayBuffer) {
this._downloadedArrayBuffers.delete(resourceData);
}
});
}
}
}

View File

@@ -5,6 +5,7 @@
*/
namespace gdjs {
const logger = new gdjs.Logger('ResourceLoader');
const debugLogger = new gdjs.Logger('ResourceLoader - debug').enable(false);
const addSearchParameterToUrl = (
url: string,
@@ -96,17 +97,15 @@ namespace gdjs {
*/
private _globalResources: Array<string>;
/**
* Resources by scene names.
* Resources and the loading state of each scene, indexed by scene name.
*/
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>;
private _sceneLoadingStates: Map<
string,
{
resourceNames: Array<string>;
status: 'not-loaded' | 'loaded' | 'ready';
}
> = new Map();
/**
* A queue of scenes whose resources are still to be pre-loaded.
*/
@@ -127,11 +126,12 @@ namespace gdjs {
private _spineManager: SpineManager | null = null;
/**
* Only used by events.
* The name of the scene for which resources are currently being loaded.
*/
private currentLoadingSceneName: string = '';
/**
* Only used by events.
* The progress, between 0 and 1, of the loading of the resource, for the
* scene that is being loaded (see `currentLoadingSceneName`).
*/
private currentSceneLoadingProgress: float = 0;
/**
@@ -144,8 +144,8 @@ namespace gdjs {
/**
* @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.
* @param globalResources The resources needed for any scene.
* @param layoutDataArray The resources used by each scene.
*/
constructor(
runtimeGame: RuntimeGame,
@@ -158,9 +158,6 @@ namespace gdjs {
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);
@@ -224,23 +221,31 @@ namespace gdjs {
): void {
this._globalResources = globalResources;
this._sceneResources.clear();
this._sceneNamesToLoad.clear();
this._sceneNamesToMakeReady.clear();
this._sceneLoadingStates.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);
this._sceneLoadingStates.set(layoutData.name, {
resourceNames: layoutData.usedResources.map(
(resource) => resource.name
),
status: 'not-loaded',
});
}
// 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));
const resourcesPreloading = layoutData.resourcesPreloading || 'inherit';
const resolvedResourcesPreloading =
resourcesPreloading === 'inherit'
? this._runtimeGame.getSceneResourcesPreloading()
: resourcesPreloading;
if (resolvedResourcesPreloading === 'at-startup') {
this._sceneToLoadQueue.push(new SceneLoadingTask(layoutData.name));
}
}
this._resources.clear();
@@ -271,8 +276,10 @@ namespace gdjs {
onProgress(loadedCount, this._resources.size);
}
);
this._sceneNamesToLoad.clear();
this._sceneNamesToMakeReady.clear();
for (const sceneLoadingState of this._sceneLoadingStates.values()) {
sceneLoadingState.status = 'ready';
}
}
/**
@@ -282,17 +289,21 @@ namespace gdjs {
firstSceneName: string,
onProgress: (count: number, total: number) => void
): Promise<void> {
const sceneResources = this._sceneResources.get(firstSceneName);
if (!sceneResources) {
const firstSceneState = this._sceneLoadingStates.get(firstSceneName);
if (!firstSceneState) {
logger.warn(
'Can\'t load resource for unknown scene: "' + firstSceneName + '".'
);
return;
}
let loadedCount = 0;
const resources = [...this._globalResources, ...sceneResources.values()];
const resourceNames = [
...this._globalResources,
...firstSceneState.resourceNames,
];
await processAndRetryIfNeededWithPromisePool(
resources,
resourceNames,
maxForegroundConcurrency,
maxAttempt,
async (resourceName) => {
@@ -304,11 +315,11 @@ namespace gdjs {
await this._loadResource(resource);
await this._processResource(resource);
loadedCount++;
onProgress(loadedCount, resources.length);
onProgress(loadedCount, resourceNames.length);
}
);
this._setSceneAssetsLoaded(firstSceneName);
this._setSceneAssetsReady(firstSceneName);
firstSceneState.status = 'ready';
}
/**
@@ -318,17 +329,32 @@ namespace gdjs {
* scenes.
*/
async loadAllSceneInBackground(): Promise<void> {
if (this.currentLoadingSceneName) {
return;
}
debugLogger.log('Loading all scene resources, in background.');
while (this._sceneToLoadQueue.length > 0) {
debugLogger.log(
`Still resources of ${this._sceneToLoadQueue.length} scene(s) to load: ${this._sceneToLoadQueue.map((task) => task.sceneName).join(', ')}`
);
const task = this._sceneToLoadQueue[this._sceneToLoadQueue.length - 1];
if (task === undefined) {
continue;
}
this.currentLoadingSceneName = task.sceneName;
if (!this.areSceneAssetsLoaded(task.sceneName)) {
debugLogger.log(
`Loading (but not processing) resources for scene ${task.sceneName}.`
);
await this._doLoadSceneResources(
task.sceneName,
async (count, total) => task.onProgress(count, total)
);
debugLogger.log(
`Done loading (but not processing) resources for scene ${task.sceneName}.`
);
// A scene may have been moved last while awaiting resources to be
// downloaded (see _prioritizeScene).
this._sceneToLoadQueue.splice(
@@ -340,6 +366,7 @@ namespace gdjs {
this._sceneToLoadQueue.pop();
}
}
debugLogger.log(`Scene resources loading finished.`);
this.currentLoadingSceneName = '';
}
@@ -347,16 +374,17 @@ namespace gdjs {
sceneName: string,
onProgress?: (count: number, total: number) => Promise<void>
): Promise<void> {
const sceneResources = this._sceneResources.get(sceneName);
if (!sceneResources) {
const sceneState = this._sceneLoadingStates.get(sceneName);
if (!sceneState) {
logger.warn(
'Can\'t load resource for unknown scene: "' + sceneName + '".'
);
return;
}
let loadedCount = 0;
await processAndRetryIfNeededWithPromisePool(
[...sceneResources.values()],
sceneState.resourceNames,
this._isLoadingInForeground
? maxForegroundConcurrency
: maxBackgroundConcurrency,
@@ -369,11 +397,13 @@ namespace gdjs {
}
await this._loadResource(resource);
loadedCount++;
this.currentSceneLoadingProgress = loadedCount / this._resources.size;
onProgress && (await onProgress(loadedCount, this._resources.size));
this.currentSceneLoadingProgress =
loadedCount / sceneState.resourceNames.length;
onProgress &&
(await onProgress(loadedCount, sceneState.resourceNames.length));
}
);
this._setSceneAssetsLoaded(sceneName);
sceneState.status = 'ready';
}
private async _loadResource(resource: ResourceData): Promise<void> {
@@ -405,8 +435,8 @@ namespace gdjs {
}
await this.loadSceneResources(sceneName, onProgress);
const sceneResources = this._sceneResources.get(sceneName);
if (!sceneResources) {
const sceneState = this._sceneLoadingStates.get(sceneName);
if (!sceneState) {
logger.warn(
'Can\'t load resource for unknown scene: "' + sceneName + '".'
);
@@ -414,7 +444,7 @@ namespace gdjs {
}
let parsedCount = 0;
for (const resourceName of sceneResources) {
for (const resourceName of sceneState.resourceNames) {
const resource = this._resources.get(resourceName);
if (!resource) {
logger.warn('Unable to find resource "' + resourceName + '".');
@@ -422,9 +452,10 @@ namespace gdjs {
}
await this._processResource(resource);
parsedCount++;
onProgress && (await onProgress(parsedCount, sceneResources.length));
onProgress &&
(await onProgress(parsedCount, sceneState.resourceNames.length));
}
this._setSceneAssetsReady(sceneName);
sceneState.status = 'ready';
}
/**
@@ -438,15 +469,25 @@ namespace gdjs {
sceneName: string,
onProgress?: (count: number, total: number) => void
): Promise<void> {
debugLogger.log(
`Prioritization of loading of resources for scene ${sceneName} was requested.`
);
this._isLoadingInForeground = true;
const task = this._prioritizeScene(sceneName);
return new Promise<void>((resolve, reject) => {
if (!task) {
this._isLoadingInForeground = false;
debugLogger.log(
`Loading of resources for scene ${sceneName} was immediately resolved.`
);
resolve();
return;
}
task.registerCallback(() => {
debugLogger.log(
`Loading of resources for scene ${sceneName} just finished.`
);
this._isLoadingInForeground = false;
resolve();
}, onProgress);
@@ -463,6 +504,51 @@ namespace gdjs {
}
}
/**
* To be called when a scene is unloaded.
*/
unloadSceneResources({
unloadedSceneName,
newSceneName,
}: {
unloadedSceneName: string;
newSceneName: string | null;
}): void {
if (!unloadedSceneName) return;
debugLogger.log(
`Unloading of resources for scene ${unloadedSceneName} was requested.`
);
const sceneUniqueResourcesByKindMap =
this._getResourcesByKindOnlyUsedInUnloadedScene({
unloadedSceneName,
newSceneName,
});
for (const [kindResourceManager, resourceManager] of this
._resourceManagersMap) {
const resources =
sceneUniqueResourcesByKindMap.get(kindResourceManager);
if (resources) {
debugLogger.log(
`Unloading of resources of kind ${kindResourceManager} for scene ${unloadedSceneName}: `,
resources.map((resource) => resource.name).join(', ')
);
resourceManager.unloadResourcesList(resources);
}
}
debugLogger.log(
`Unloading of resources for scene ${unloadedSceneName} finished.`
);
const sceneState = this._sceneLoadingStates.get(unloadedSceneName);
if (sceneState) {
sceneState.status = 'not-loaded';
}
// TODO: mark the scene as unloaded so it's not automatically loaded again eagerly.
}
/**
* Put a given scene at the end of the queue.
*
@@ -470,16 +556,41 @@ namespace gdjs {
* this scene will be the next to be loaded.
*/
private _prioritizeScene(sceneName: string): SceneLoadingTask | null {
const sceneState = this._sceneLoadingStates.get(sceneName);
if (!sceneState) return null;
if (sceneState.status === 'loaded' || sceneState.status === 'ready') {
debugLogger.log(
`Scene ${sceneName} is already loaded. Skipping prioritization.`
);
// The scene is already loaded, nothing to do.
return null;
}
// The scene is not loaded: either prioritize it or add it to the loading queue.
const taskIndex = this._sceneToLoadQueue.findIndex(
(task) => task.sceneName === sceneName
);
if (taskIndex < 0) {
// The scene is already loaded.
return null;
let task: SceneLoadingTask;
if (taskIndex !== -1) {
// There is already a task for this scene in the queue.
// Move it so that it's loaded first.
task = this._sceneToLoadQueue[taskIndex];
this._sceneToLoadQueue.splice(taskIndex, 1);
this._sceneToLoadQueue.push(task);
} else {
// There is no task for this scene in the queue.
// It might be because the scene was unloaded or never loaded.
// In this case, we need to add a new task to the queue.
task = new SceneLoadingTask(sceneName);
this._sceneToLoadQueue.push(task);
}
const task = this._sceneToLoadQueue[taskIndex];
this._sceneToLoadQueue.splice(taskIndex, 1);
this._sceneToLoadQueue.push(task);
// Re-start the loading process in the background. While at the beginning of the game
// it's not needed because already launched, a scene might be unloaded. This means
// that we then need to relaunch the loading process.
this.loadAllSceneInBackground();
return task;
}
@@ -511,7 +622,10 @@ namespace gdjs {
* (but maybe not parsed).
*/
areSceneAssetsLoaded(sceneName: string): boolean {
return !this._sceneNamesToLoad.has(sceneName);
const sceneState = this._sceneLoadingStates.get(sceneName);
if (!sceneState) return false;
return sceneState.status === 'loaded' || sceneState.status === 'ready';
}
/**
@@ -519,15 +633,10 @@ namespace gdjs {
* parsed.
*/
areSceneAssetsReady(sceneName: string): boolean {
return !this._sceneNamesToMakeReady.has(sceneName);
}
const sceneState = this._sceneLoadingStates.get(sceneName);
if (!sceneState) return false;
private _setSceneAssetsLoaded(sceneName: string): void {
this._sceneNamesToLoad.delete(sceneName);
}
private _setSceneAssetsReady(sceneName: string): void {
this._sceneNamesToMakeReady.delete(sceneName);
return sceneState.status === 'ready';
}
getResource(resourceName: string): ResourceData | null {
@@ -636,6 +745,70 @@ namespace gdjs {
getSpineAtlasManager(): gdjs.SpineAtlasManager | null {
return this._spineAtlasManager;
}
injectMockResourceManagerForTesting(
resourceKind: ResourceKind,
resourceManager: ResourceManager
) {
this._resourceManagersMap.set(resourceKind, resourceManager);
}
/**
* Get the map of resources that are only used in the scene that is being unloaded,
* and that are not used in any other loaded scene (or the scene that is coming next).
*/
private _getResourcesByKindOnlyUsedInUnloadedScene({
unloadedSceneName,
newSceneName,
}: {
unloadedSceneName: string;
newSceneName: string | null;
}): Map<ResourceKind, ResourceData[]> {
const unloadedSceneState =
this._sceneLoadingStates.get(unloadedSceneName);
if (!unloadedSceneState) {
return new Map<ResourceKind, ResourceData[]>();
}
// Construct the set of all resources to unload. These are the resources
// used in the scene that is being unloaded minus all the resources used
// by the other scenes that are loaded (and the possible scene that is coming next).
const resourceNamesToUnload = new Set<string>(
unloadedSceneState.resourceNames
);
for (const [
sceneName,
sceneState,
] of this._sceneLoadingStates.entries()) {
if (sceneName === unloadedSceneName) continue;
if (
sceneName === newSceneName ||
sceneState.status === 'loaded' ||
sceneState.status === 'ready'
) {
sceneState.resourceNames.forEach((resourceName) => {
resourceNamesToUnload.delete(resourceName);
});
}
}
const result = new Map<ResourceKind, ResourceData[]>();
resourceNamesToUnload.forEach((resourceName) => {
const resourceData = this._resources.get(resourceName);
if (!resourceData) return;
const kind = resourceData.kind;
const resources = result.get(kind);
if (resources) {
resources.push(resourceData);
} else {
result.set(kind, [resourceData]);
}
});
return result;
}
}
type PromiseError<T> = { item: T; error: Error };

View File

@@ -35,5 +35,15 @@ namespace gdjs {
* Using the manager after calling this method is undefined behavior.
*/
dispose(): void;
/**
* Should clear all specified resources data and anything stored by this manager
* for these resources.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources that need to be clear
*/
unloadResourcesList(resourcesList: ResourceData[]): void;
}
}

View File

@@ -205,6 +205,28 @@ namespace gdjs {
this._loadedFontFamily.clear();
this._loadedFontFamilySet.clear();
}
/**
* Unload the specified list of resources:
* this clears the caches of loaded font families.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const resource = this._loadedFontFamily.get(resourceData);
if (resource) {
this._loadedFontFamily.delete(resourceData);
}
const fontName = this._getFontFamilyFromFilename(resourceData);
if (fontName) {
this._loadedFontFamilySet.delete(fontName);
}
});
}
}
//Register the class to let the engine use it.

View File

@@ -939,6 +939,28 @@ namespace gdjs {
dispose(): void {
this.unloadAll();
}
/**
* Unload the specified list of resources:
* this unloads all audio from the specified resources from memory.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const musicRes = this._loadedMusics.get(resourceData);
if (musicRes) {
this.unloadAudio(resourceData.name, true);
}
const soundRes = this._loadedSounds.get(resourceData);
if (soundRes) {
this.unloadAudio(resourceData.name, false);
}
});
}
}
// Register the class to let the engine use it.

View File

@@ -208,5 +208,26 @@ namespace gdjs {
this._loadedJsons.clear();
this._callbacks.clear();
}
/**
* Unload the specified list of resources:
* this clears the JSONs loaded in this manager.
*
* Usually called when scene resoures are unloaded.
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedJson = this._loadedJsons.get(resourceData);
if (loadedJson) {
this._loadedJsons.delete(resourceData);
}
const callback = this._callbacks.get(resourceData);
if (callback) {
this._callbacks.delete(resourceData);
}
});
}
}
}

View File

@@ -66,6 +66,7 @@ namespace gdjs {
*/
export class Logger {
private readonly group: string;
private enabled: boolean = true;
/**
* Create a new logger with the given group name.
@@ -76,21 +77,30 @@ namespace gdjs {
}
log(...messages: any[]): void {
if (!this.enabled) return;
loggerOutput.log(this.group, objectsToString(messages), 'info');
}
info(...messages: any[]): void {
if (!this.enabled) return;
loggerOutput.log(this.group, objectsToString(messages), 'info');
}
warn(...messages: any[]): void {
if (!this.enabled) return;
loggerOutput.log(this.group, objectsToString(messages), 'warning');
}
error(...messages: any[]): void {
if (!this.enabled) return;
loggerOutput.log(this.group, objectsToString(messages), 'error');
}
enable(enabled: boolean): gdjs.Logger {
this.enabled = enabled;
return this;
}
/**
* Give access to the console output used by default by the logger.
* This can be useful to restore the default log method if you overrode it

View File

@@ -307,6 +307,34 @@ namespace gdjs {
this._pixiBitmapFontsToUninstall.length = 0;
this._loadedFontsData.clear();
}
/**
* Unload the specified list of resources:
* this uninstalls fonts from memory and clear cache of loaded fonts.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const loadedFont = this._loadedFontsData.get(resourceData);
if (loadedFont) {
this._loadedFontsData.delete(resourceData);
}
for (const bitmapFontInstallKey in this._pixiBitmapFontsInUse) {
if (bitmapFontInstallKey.endsWith(resourceData.file))
PIXI.BitmapFont.uninstall(bitmapFontInstallKey);
}
for (const bitmapFontInstallKey of this._pixiBitmapFontsToUninstall) {
if (bitmapFontInstallKey.endsWith(resourceData.file))
PIXI.BitmapFont.uninstall(bitmapFontInstallKey);
}
});
}
}
// Register the class to let the engine use it.

View File

@@ -519,6 +519,37 @@ namespace gdjs {
}
this._scaledTextures.clear();
}
/**
* Unload the specified list of resources:
* this clears the cache of loaded textures associated to these resources.
*
* Usually called when scene resoures are unloaded.
*
* @param resourcesList The list of specific resources
*/
unloadResourcesList(resourcesList: ResourceData[]): void {
resourcesList.forEach((resourceData) => {
const resourceName = resourceData.name;
const resource = this._loadedTextures.get(resourceData);
if (resource) {
resource.destroy(true);
this._loadedTextures.delete(resourceData);
}
const threeTexture = this._loadedThreeTextures.get(resourceName);
if (threeTexture) {
threeTexture.dispose();
this._loadedThreeTextures.remove(resourceName);
}
const threeMaterials = this._loadedThreeMaterials.get(resourceName);
if (threeMaterials) {
threeMaterials.dispose();
this._loadedThreeMaterials.remove(resourceName);
}
});
}
}
//Register the class to let the engine use it.

View File

@@ -184,6 +184,9 @@ namespace gdjs {
*/
_embeddedResourcesMappings: Map<string, Record<string, string>>;
_sceneResourcesPreloading: 'at-startup' | 'never';
_sceneResourcesUnloading: 'at-scene-exit' | 'never';
/**
* Optional client to connect to a debugger server.
*/
@@ -223,6 +226,11 @@ namespace gdjs {
this._data = data;
this._updateSceneAndExtensionsData();
this._sceneResourcesPreloading =
this._data.properties.sceneResourcesPreloading || 'at-startup';
this._sceneResourcesUnloading =
this._data.properties.sceneResourcesUnloading || 'never';
this._resourcesLoader = new gdjs.ResourceLoader(
this,
data.resources.resources,
@@ -245,6 +253,7 @@ namespace gdjs {
this._antialiasingMode = this._data.properties.antialiasingMode;
this._isAntialisingEnabledOnMobile =
this._data.properties.antialisingEnabledOnMobile;
this._renderer = new gdjs.RuntimeGameRenderer(
this,
this._options.forceFullscreen || false
@@ -363,6 +372,14 @@ namespace gdjs {
return this._variablesByExtensionName.get(extensionName) || null;
}
/**
* Get the gdjs.ResourceLoader of the RuntimeGame.
* @return The resource loader.
*/
getResourceLoader(): gdjs.ResourceLoader {
return this._resourcesLoader;
}
/**
* Get the gdjs.SoundManager of the RuntimeGame.
* @return The sound manager.
@@ -764,6 +781,22 @@ namespace gdjs {
return this._resourcesLoader.areSceneAssetsReady(sceneName);
}
/**
* Returns the scene resources preloading mode.
* It can be overriden by each scene.
*/
getSceneResourcesPreloading(): 'at-startup' | 'never' {
return this._sceneResourcesPreloading;
}
/**
* Returns the scene resources unloading mode.
* It can be overriden by each scene.
*/
getSceneResourcesUnloading(): 'at-scene-exit' | 'never' {
return this._sceneResourcesUnloading;
}
/**
* Load all assets needed to display the 1st scene, displaying progress in
* renderer.

View File

@@ -23,6 +23,7 @@ namespace gdjs {
_timeManager: TimeManager;
_gameStopRequested: boolean = false;
_requestedScene: string = '';
_resourcesUnloading: 'at-scene-exit' | 'never' | 'inherit' = 'inherit';
private _asyncTasksManager = new gdjs.AsyncTasksManager();
/** True if loadFromScene was called and the scene is being played. */
@@ -141,6 +142,7 @@ namespace gdjs {
this._runtimeGame.getRenderer().setWindowTitle(sceneData.title);
}
this._name = sceneData.name;
this._resourcesUnloading = sceneData.resourcesUnloading || 'inherit';
this.setBackgroundColor(sceneData.r, sceneData.v, sceneData.b);
//Load layers
@@ -578,6 +580,13 @@ namespace gdjs {
return this._name;
}
/**
* Get the strategy to unload resources of this scene.
*/
getResourcesUnloading(): 'at-scene-exit' | 'never' | 'inherit' {
return this._resourcesUnloading;
}
/**
* Create an identifier for a new object of the scene.
*/

View File

@@ -63,10 +63,14 @@ namespace gdjs {
this.pop();
} else if (request === gdjs.SceneChangeRequest.PUSH_SCENE) {
this.push(currentScene.getRequestedScene());
} else if (request === gdjs.SceneChangeRequest.REPLACE_SCENE) {
this.replace(currentScene.getRequestedScene());
} else if (request === gdjs.SceneChangeRequest.CLEAR_SCENES) {
this.replace(currentScene.getRequestedScene(), true);
} else if (
request === gdjs.SceneChangeRequest.REPLACE_SCENE ||
request === gdjs.SceneChangeRequest.CLEAR_SCENES
) {
this.replace(
currentScene.getRequestedScene(),
request === gdjs.SceneChangeRequest.CLEAR_SCENES
);
} else {
logger.error('Unrecognized change in scene stack: ' + request);
}
@@ -101,7 +105,10 @@ namespace gdjs {
if (!scene) {
return;
}
scene.unloadScene();
this._unloadSceneAndPossiblyResources({
scene,
newSceneName: null,
});
}
// Tell the new current scene it's being resumed
@@ -140,6 +147,7 @@ namespace gdjs {
this._loadNewScene(newSceneName);
this._isNextLayoutLoading = false;
});
return null;
}
@@ -186,7 +194,7 @@ namespace gdjs {
while (this._stack.length !== 0) {
let scene = this._stack.pop();
if (scene) {
scene.unloadScene();
this._unloadSceneAndPossiblyResources({ scene, newSceneName });
}
}
} else {
@@ -194,7 +202,7 @@ namespace gdjs {
if (this._stack.length !== 0) {
let scene = this._stack.pop();
if (scene) {
scene.unloadScene();
this._unloadSceneAndPossiblyResources({ scene, newSceneName });
}
}
}
@@ -372,14 +380,52 @@ namespace gdjs {
* Unload all the scenes and clear the stack.
*/
dispose(): void {
for (const item of this._stack) {
item.unloadScene();
while (this._stack.length > 0) {
const scene = this._stack.pop();
if (scene) {
this._unloadSceneAndPossiblyResources({
scene,
newSceneName: null,
});
}
}
this._stack.length = 0;
this._wasDisposed = true;
}
private _unloadSceneAndPossiblyResources({
scene,
newSceneName,
}: {
scene: gdjs.RuntimeScene;
newSceneName: string | null;
}): void {
const unloadedSceneName = scene.getName();
const resourcesUnloading = scene.getResourcesUnloading();
const resolvedResourcesUnloading =
resourcesUnloading === 'inherit'
? this._runtimeGame.getSceneResourcesUnloading()
: resourcesUnloading;
const shouldUnloadResources =
resolvedResourcesUnloading === 'at-scene-exit' &&
// Unload resources only if it's the last scene with this name in the stack.
newSceneName !== scene.getName() &&
this._stack.every((scene) => scene.getName() !== unloadedSceneName);
scene.unloadScene();
// After this point, `scene` is no longer valid and should not be used anymore.
// It was "disposed".
if (shouldUnloadResources) {
this._runtimeGame.getResourceLoader().unloadSceneResources({
unloadedSceneName,
newSceneName,
});
}
}
private _throwIfDisposed(): void {
if (this._wasDisposed) {
throw 'The scene stack has been disposed and should not be used anymore.';

View File

@@ -169,6 +169,8 @@ declare interface LayoutData extends InstanceContainerData {
title: string;
behaviorsSharedData: BehaviorSharedData[];
usedResources: ResourceReference[];
resourcesPreloading?: 'at-startup' | 'never' | 'inherit';
resourcesUnloading?: 'at-scene-exit' | 'never' | 'inherit';
}
declare interface LayoutNetworkSyncData {
@@ -370,6 +372,8 @@ declare interface ProjectPropertiesData {
extensionProperties: Array<ExtensionProperty>;
useDeprecatedZeroAsDefaultZOrder?: boolean;
projectUuid?: string;
sceneResourcesPreloading?: 'at-startup' | 'never';
sceneResourcesUnloading?: 'at-scene-exit' | 'never';
}
declare interface ExtensionProperty {
@@ -437,4 +441,5 @@ declare type ResourceKind =
| 'bitmapFont'
| 'model3D'
| 'atlas'
| 'spine';
| 'spine'
| 'fake-resource-kind-for-testing-only';

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 B

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,47 @@
// @ts-check
/*
* 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
/**
* @implements {gdjs.ResourceManager}
*/
gdjs.MockedResourceManager = class MockedResourceManager {
loadResourcePromises = new Map();
loadResourceCallbacks = new Map();
disposedResources = new Set();
loadedResources = new Set();
waitingForProcessing = new Set();
readyResources = new Set();
loadResource(resourceName) {
const that = this;
return new Promise((resolve, reject) => {
that.loadResourceCallbacks.set(resourceName, resolve);
if (
this.loadedResources.has(resourceName) ||
this.waitingForProcessing.has(resourceName)
) {
return Promise.resolve();
}
const existingPromise = this.loadResourcePromises.get(resourceName);
if (existingPromise) {
return existingPromise;
}
const promise = new Promise((resolve) => {
this.loadResourceCallbacks.set(resourceName, resolve);
});
this.loadResourcePromises.set(resourceName, promise);
return promise;
}
async processResource(resourceName) {}
async processResource(resourceName) {
// Mark resource as fully processed
this.readyResources.add(resourceName);
}
/**
* @param {string} resourceName
* @param {string} resourceName
* @returns {boolean}
*/
isResourceDownloadPending(resourceName) {
@@ -26,15 +49,64 @@ gdjs.MockedResourceManager = class MockedResourceManager {
}
/**
* @param {string} resourceName
* @param {string} resourceName
*/
markPendingResourcesAsLoaded(resourceName) {
const loadResourceCallback = this.loadResourceCallbacks.get(resourceName);
loadResourceCallback();
this.loadResourceCallbacks.delete(resourceName);
if (loadResourceCallback) {
this.loadedResources.add(resourceName);
loadResourceCallback();
this.loadResourceCallbacks.delete(resourceName);
this.loadResourcePromises.delete(resourceName);
} else {
throw new Error(
`Resource ${resourceName} was not being loaded, so cannot be marked as loaded.`
);
}
}
/**
* Check if a resource is loaded (but maybe not yet processed)
*/
isResourceLoaded(resourceName) {
return this.loadedResources.has(resourceName);
}
/**
* Check if a resource has been disposed
*/
isResourceDisposed(resourceName) {
return this.disposedResources.has(resourceName);
}
/**
* Dispose all resources
*/
dispose() {
for (const resourceName of this.loadedResources) {
this.disposedResources.add(resourceName);
}
this.loadedResources.clear();
this.loadResourceCallbacks.clear();
this.loadResourcePromises.clear();
}
/**
* Dispose specific resources
*/
unloadResourcesList(resourcesList) {
for (const resource of resourcesList) {
this.disposedResources.add(resource.name);
this.loadedResources.delete(resource.name);
this.loadResourceCallbacks.delete(resource.name);
this.loadResourcePromises.delete(resource.name);
}
}
/**
* @returns {ResourceKind[]}
*/
getResourceKinds() {
return ['fake-heavy-resource'];
return ['fake-resource-kind-for-testing-only'];
}
}

View File

@@ -0,0 +1,331 @@
// @ts-check
/**
* Tests for gdjs.ResourceLoader.
*/
describe('gdjs.ResourceLoader', () => {
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
/** @returns {LayoutData} */
const createSceneData = (name, usedResources) => {
return {
r: 0,
v: 0,
b: 0,
mangledName: name,
name,
objects: [],
layers: [],
instances: [],
behaviorsSharedData: [],
stopSoundsOnStartup: false,
title: '',
variables: [],
usedResources,
};
};
/** @type {{layouts?: LayoutData[], resources?: ResourcesData}} */
const gameSettingsWithThreeScenes = {
layouts: [
createSceneData('Scene1', [
{ name: 'scene1-resource1.png' },
{ name: 'scene1-resource2.png' },
]),
createSceneData('Scene2', [
{ name: 'scene2-resource1.png' },
{ name: 'shared-resource.png' },
]),
createSceneData('Scene3', [
{ name: 'scene3-resource1.png' },
{ name: 'shared-resource.png' },
]),
],
resources: {
resources: [
{
kind: 'fake-resource-kind-for-testing-only',
name: 'scene1-resource1.png',
metadata: '',
file: 'scene1-resource1.png',
userAdded: true,
},
{
kind: 'fake-resource-kind-for-testing-only',
name: 'scene1-resource2.png',
metadata: '',
file: 'scene1-resource2.png',
userAdded: true,
},
{
kind: 'fake-resource-kind-for-testing-only',
name: 'scene2-resource1.png',
metadata: '',
file: 'scene2-resource1.png',
userAdded: true,
},
{
kind: 'fake-resource-kind-for-testing-only',
name: 'scene3-resource1.png',
metadata: '',
file: 'scene3-resource1.png',
userAdded: true,
},
{
kind: 'fake-resource-kind-for-testing-only',
name: 'shared-resource.png',
metadata: '',
file: 'shared-resource.png',
userAdded: true,
},
],
},
};
it('should load first scene resources, then others in background', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithThreeScenes);
const resourceLoader = runtimeGame.getResourceLoader();
resourceLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
// Initially, no scene assets should be loaded
expect(resourceLoader.areSceneAssetsLoaded('Scene1')).to.be(false);
expect(resourceLoader.areSceneAssetsLoaded('Scene2')).to.be(false);
expect(resourceLoader.areSceneAssetsLoaded('Scene3')).to.be(false);
expect(resourceLoader.areSceneAssetsReady('Scene1')).to.be(false);
expect(resourceLoader.areSceneAssetsReady('Scene2')).to.be(false);
expect(resourceLoader.areSceneAssetsReady('Scene3')).to.be(false);
// Start loading first scene and background loading
runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene1');
// Scene1 resources should be pending download
expect(
mockedResourceManager.isResourceDownloadPending('scene1-resource1.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDownloadPending('scene1-resource2.png')
).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene1')).to.be(false);
// Mark Scene1 resources as loaded
mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource1.png');
mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource2.png');
await delay(10);
// Scene1 should now be ready
expect(resourceLoader.areSceneAssetsLoaded('Scene1')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene1')).to.be(true);
// Background loading should have started for Scene2
await delay(20); // Wait for background loading to start
expect(
mockedResourceManager.isResourceDownloadPending('scene2-resource1.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDownloadPending('shared-resource.png')
).to.be(true);
// Mark Scene2 resources as loaded
mockedResourceManager.markPendingResourcesAsLoaded('scene2-resource1.png');
mockedResourceManager.markPendingResourcesAsLoaded('shared-resource.png');
await delay(10);
// Scene2 should now be loaded
expect(resourceLoader.areSceneAssetsLoaded('Scene2')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene2')).to.be(true);
expect(resourceLoader.areSceneAssetsLoaded('Scene3')).to.be(false);
expect(resourceLoader.areSceneAssetsReady('Scene3')).to.be(false);
// Background loading should have started for Scene3
await delay(20); // Wait for background loading to start
expect(
mockedResourceManager.isResourceDownloadPending('scene3-resource1.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDownloadPending('shared-resource.png')
// shared-resource.png should already be loaded, so not pending:
).to.be(false);
// Mark Scene3 resources as loaded
mockedResourceManager.markPendingResourcesAsLoaded('scene3-resource1.png');
await delay(10);
// All scenes should now be loaded
expect(resourceLoader.areSceneAssetsLoaded('Scene3')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene3')).to.be(true);
});
it('should unload only resources unique to the unloaded scene', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithThreeScenes);
const resourceLoader = runtimeGame.getResourceLoader();
resourceLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
// Load all resources for all scenes
resourceLoader.loadGlobalAndFirstSceneResources('Scene1', () => {});
mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource1.png');
mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource2.png');
resourceLoader.loadAndProcessSceneResources('Scene2');
mockedResourceManager.markPendingResourcesAsLoaded('scene2-resource1.png');
mockedResourceManager.markPendingResourcesAsLoaded('shared-resource.png');
await delay(10);
resourceLoader.loadAndProcessSceneResources('Scene3');
mockedResourceManager.markPendingResourcesAsLoaded('scene3-resource1.png');
await delay(10);
// Verify all resources are loaded
expect(resourceLoader.areSceneAssetsReady('Scene1')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene2')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene3')).to.be(true);
// Verify no resources are disposed initially
expect(
mockedResourceManager.isResourceDisposed('scene1-resource1.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('scene1-resource2.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('scene2-resource1.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('scene3-resource1.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('shared-resource.png')
).to.be(false);
// Simulate Scene2 and Scene3 being loaded/active by marking Scene1 as unloaded
// while Scene2 will be the new scene
resourceLoader.unloadSceneResources({
unloadedSceneName: 'Scene1',
newSceneName: 'Scene2',
});
// Only Scene1-specific resources should be disposed
// shared-resource.png should NOT be disposed because it's used in Scene2 and Scene3
expect(
mockedResourceManager.isResourceDisposed('scene1-resource1.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDisposed('scene1-resource2.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDisposed('scene2-resource1.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('scene3-resource1.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('shared-resource.png')
).to.be(false);
// Scene1 should be marked as not loaded
expect(resourceLoader.areSceneAssetsLoaded('Scene1')).to.be(false);
expect(resourceLoader.areSceneAssetsReady('Scene1')).to.be(false);
// Other scenes should still be loaded
expect(resourceLoader.areSceneAssetsLoaded('Scene2')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene2')).to.be(true);
expect(resourceLoader.areSceneAssetsLoaded('Scene3')).to.be(true);
expect(resourceLoader.areSceneAssetsReady('Scene3')).to.be(true);
});
it('should unload shared resources only when no other scene uses them', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithThreeScenes);
const resourceLoader = runtimeGame.getResourceLoader();
resourceLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
// Load all resources for all scenes
resourceLoader.loadAllResources(() => {});
await delay(10);
// First, unload Scene2 (which shares resources with Scene3)
resourceLoader.unloadSceneResources({
unloadedSceneName: 'Scene2',
newSceneName: 'Scene3',
});
// Only Scene2-specific resources should be disposed
// shared-resource.png should NOT be disposed because it's still used in Scene3
expect(
mockedResourceManager.isResourceDisposed('scene2-resource1.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDisposed('shared-resource.png')
).to.be(false);
// Now unload Scene3 (which also uses shared-resource.png)
resourceLoader.unloadSceneResources({
unloadedSceneName: 'Scene3',
newSceneName: 'Scene1',
});
// Now shared-resource.png should be disposed because no loaded scene uses it
expect(
mockedResourceManager.isResourceDisposed('scene3-resource1.png')
).to.be(true);
expect(
mockedResourceManager.isResourceDisposed('shared-resource.png')
).to.be(true);
// Scene1 resources should still be loaded
expect(
mockedResourceManager.isResourceDisposed('scene1-resource1.png')
).to.be(false);
expect(
mockedResourceManager.isResourceDisposed('scene1-resource2.png')
).to.be(false);
});
it('should handle background scene loading progress correctly', async () => {
const mockedResourceManager = new gdjs.MockedResourceManager();
const runtimeGame = gdjs.getPixiRuntimeGame(gameSettingsWithThreeScenes);
const resourceLoader = runtimeGame.getResourceLoader();
resourceLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
// Initially progress should be 0
expect(resourceLoader.getSceneLoadingProgress('Scene1')).to.be(0);
expect(resourceLoader.getSceneLoadingProgress('Scene2')).to.be(0);
// Start loading first scene
runtimeGame.loadFirstAssetsAndStartBackgroundLoading('Scene1');
mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource1.png');
mockedResourceManager.markPendingResourcesAsLoaded('scene1-resource2.png');
await delay(10);
// Progress should still be 0 until resources start loading
expect(resourceLoader.getSceneLoadingProgress('Scene2')).to.be(0);
// Mark first resource as loaded
mockedResourceManager.markPendingResourcesAsLoaded('scene2-resource1.png');
await delay(10);
// Progress should be partial (1 out of 2 resources)
console.log(resourceLoader.getSceneLoadingProgress('Scene2'));
expect(resourceLoader.getSceneLoadingProgress('Scene2')).to.be(0.5);
// Mark second resource as loaded
mockedResourceManager.markPendingResourcesAsLoaded('shared-resource.png');
await delay(10);
// Progress should be complete (1.0)
expect(resourceLoader.getSceneLoadingProgress('Scene2')).to.be(1);
});
});

View File

@@ -6,7 +6,7 @@
describe('gdjs.SceneStack', () => {
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
const createSene = (name, usedResources) => {
const createSceneData = (name, usedResources) => {
return {
r: 0,
v: 0,
@@ -24,10 +24,13 @@ describe('gdjs.SceneStack', () => {
};
};
/** @type {{layouts?: LayoutData[], resources?: ResourcesData}} */
const gameSettings = {
layouts: [
createSene('Scene 1', []),
createSene('Scene 2', [{ name: 'base/tests-utils/assets/64x64.jpg' }]),
createSceneData('Scene 1', []),
createSceneData('Scene 2', [
{ name: 'base/tests-utils/assets/64x64.jpg' },
]),
],
resources: {
resources: [
@@ -43,11 +46,11 @@ describe('gdjs.SceneStack', () => {
};
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(() => {});
const resourcesLoader = runtimeGame.getResourceLoader();
await resourcesLoader.loadAllResources(() => {});
// Set up some scene callbacks.
/** @type gdjs.RuntimeScene | null */
@@ -146,38 +149,39 @@ describe('gdjs.SceneStack', () => {
gdjs._unregisterCallback(onRuntimeSceneResumed);
});
/** @type {{layouts?: LayoutData[], resources?: ResourcesData}} */
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' }]),
createSceneData('Scene 1', [{ name: 'fake-heavy-resource1.png' }]),
createSceneData('Scene 2', [{ name: 'fake-heavy-resource2.png' }]),
createSceneData('Scene 3', [{ name: 'fake-heavy-resource3.png' }]),
createSceneData('Scene 4', [{ name: 'fake-heavy-resource4.png' }]),
],
resources: {
resources: [
{
kind: 'fake-heavy-resource',
kind: 'fake-resource-kind-for-testing-only',
name: 'fake-heavy-resource1.png',
metadata: '',
file: 'fake-heavy-resource1.png',
userAdded: true,
},
{
kind: 'fake-heavy-resource',
kind: 'fake-resource-kind-for-testing-only',
name: 'fake-heavy-resource2.png',
metadata: '',
file: 'fake-heavy-resource2.png',
userAdded: true,
},
{
kind: 'fake-heavy-resource',
kind: 'fake-resource-kind-for-testing-only',
name: 'fake-heavy-resource3.png',
metadata: '',
file: 'fake-heavy-resource3.png',
userAdded: true,
},
{
kind: 'fake-heavy-resource',
kind: 'fake-resource-kind-for-testing-only',
name: 'fake-heavy-resource4.png',
metadata: '',
file: 'fake-heavy-resource4.png',
@@ -189,11 +193,11 @@ describe('gdjs.SceneStack', () => {
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',
const resourcesLoader = runtimeGame.getResourceLoader();
resourcesLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
let sceneStack = runtimeGame._sceneStack;
@@ -299,11 +303,11 @@ describe('gdjs.SceneStack', () => {
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',
const resourcesLoader = runtimeGame.getResourceLoader();
resourcesLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
let sceneStack = runtimeGame._sceneStack;
@@ -404,11 +408,10 @@ describe('gdjs.SceneStack', () => {
it("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',
const resourcesLoader = runtimeGame.getResourceLoader();
resourcesLoader.injectMockResourceManagerForTesting(
'fake-resource-kind-for-testing-only',
mockedResourceManager
);
let sceneStack = runtimeGame._sceneStack;

View File

@@ -666,6 +666,11 @@ interface Project {
[Ref] ObjectsContainer GetObjects();
[Ref] ResourcesManager GetResourcesManager();
void SetSceneResourcesPreloading([Const] DOMString resourcesPreloading);
[Const, Ref] DOMString GetSceneResourcesPreloading();
void SetSceneResourcesUnloading([Const] DOMString resourcesUnloading);
[Const, Ref] DOMString GetSceneResourcesUnloading();
void SerializeTo([Ref] SerializerElement element);
void UnserializeFrom([Const, Ref] SerializerElement element);
@@ -1002,6 +1007,11 @@ interface Layout {
void SetStopSoundsOnStartup(boolean enable);
boolean StopSoundsOnStartup();
void SetResourcesPreloading([Const] DOMString resourcesPreloading);
[Const, Ref] DOMString GetResourcesPreloading();
void SetResourcesUnloading([Const] DOMString resourcesUnloading);
[Const, Ref] DOMString GetResourcesUnloading();
};
interface ExternalEvents {

View File

@@ -619,6 +619,10 @@ export class Project extends EmscriptenObject {
getVariables(): VariablesContainer;
getObjects(): ObjectsContainer;
getResourcesManager(): ResourcesManager;
setSceneResourcesPreloading(resourcesPreloading: string): void;
getSceneResourcesPreloading(): string;
setSceneResourcesUnloading(resourcesUnloading: string): void;
getSceneResourcesUnloading(): string;
serializeTo(element: SerializerElement): void;
unserializeFrom(element: SerializerElement): void;
getWholeProjectDiagnosticReport(): WholeProjectDiagnosticReport;
@@ -823,6 +827,10 @@ export class Layout extends EmscriptenObject {
unserializeFrom(project: Project, element: SerializerElement): void;
setStopSoundsOnStartup(enable: boolean): void;
stopSoundsOnStartup(): boolean;
setResourcesPreloading(resourcesPreloading: string): void;
getResourcesPreloading(): string;
setResourcesUnloading(resourcesUnloading: string): void;
getResourcesUnloading(): string;
}
export class ExternalEvents extends EmscriptenObject {

View File

@@ -32,6 +32,10 @@ declare class gdLayout {
unserializeFrom(project: gdProject, element: gdSerializerElement): void;
setStopSoundsOnStartup(enable: boolean): void;
stopSoundsOnStartup(): boolean;
setResourcesPreloading(resourcesPreloading: string): void;
getResourcesPreloading(): string;
setResourcesUnloading(resourcesUnloading: string): void;
getResourcesUnloading(): string;
delete(): void;
ptr: number;
};

View File

@@ -107,6 +107,10 @@ declare class gdProject {
getVariables(): gdVariablesContainer;
getObjects(): gdObjectsContainer;
getResourcesManager(): gdResourcesManager;
setSceneResourcesPreloading(resourcesPreloading: string): void;
getSceneResourcesPreloading(): string;
setSceneResourcesUnloading(resourcesUnloading: string): void;
getSceneResourcesUnloading(): string;
serializeTo(element: gdSerializerElement): void;
unserializeFrom(element: gdSerializerElement): void;
getWholeProjectDiagnosticReport(): gdWholeProjectDiagnosticReport;

View File

@@ -80,6 +80,8 @@ type ProjectProperties = {|
androidIconResourceNames: Array<string>,
androidWindowSplashScreenAnimatedIconResourceName: string,
iosIconResourceNames: Array<string>,
sceneResourcesPreloading: string,
sceneResourcesUnloading: string,
|};
const loadPropertiesFromProject = (project: gdProject): ProjectProperties => {
@@ -118,6 +120,8 @@ const loadPropertiesFromProject = (project: gdProject): ProjectProperties => {
iosIconResourceNames: iosIconSizes.map(size =>
platformSpecificAssets.get('ios', `icon-${size}`)
),
sceneResourcesPreloading: project.getSceneResourcesPreloading(),
sceneResourcesUnloading: project.getSceneResourcesUnloading(),
};
};
@@ -151,6 +155,8 @@ function applyPropertiesToProject(
androidIconResourceNames,
androidWindowSplashScreenAnimatedIconResourceName,
iosIconResourceNames,
sceneResourcesPreloading,
sceneResourcesUnloading,
} = newProperties;
project.setGameResolutionSize(gameResolutionWidth, gameResolutionHeight);
project.setAdaptGameResolutionAtRuntime(adaptGameResolutionAtRuntime);
@@ -177,6 +183,8 @@ function applyPropertiesToProject(
project.setMaximumFPS(maxFPS);
project.setFolderProject(isFolderProject);
project.setUseDeprecatedZeroAsDefaultZOrder(useDeprecatedZeroAsDefaultZOrder);
project.setSceneResourcesPreloading(sceneResourcesPreloading);
project.setSceneResourcesUnloading(sceneResourcesUnloading);
const platformSpecificAssets = project.getPlatformSpecificAssets();
desktopIconSizes.forEach((size, index) => {
@@ -287,6 +295,14 @@ const ProjectPropertiesDialog = (props: Props) => {
>(
iosIconSizes.map(size => platformSpecificAssets.get('ios', `icon-${size}`))
);
const [
sceneResourcesPreloading,
setSceneResourcesPreloading,
] = React.useState<string>(initialProperties.sceneResourcesPreloading);
const [
sceneResourcesUnloading,
setSceneResourcesUnloading,
] = React.useState<string>(initialProperties.sceneResourcesUnloading);
const { isMobile } = useResponsiveWindowSize();
@@ -347,6 +363,8 @@ const ProjectPropertiesDialog = (props: Props) => {
androidIconResourceNames,
androidWindowSplashScreenAnimatedIconResourceName,
iosIconResourceNames,
sceneResourcesPreloading,
sceneResourcesUnloading,
}
);
@@ -827,6 +845,43 @@ const ProjectPropertiesDialog = (props: Props) => {
label={t`Multiple files, saved in folder next to the main file`}
/>
</SelectField>
<Text size="block-title">
<Trans>Resources loading</Trans>
</Text>
<SelectField
fullWidth
floatingLabelText={
<Trans>Background preloading of scene resources</Trans>
}
value={sceneResourcesPreloading}
onChange={e => setSceneResourcesPreloading(e.target.value)}
helperMarkdownText={i18n._(
t`This can be customized for each scene in the scene properties dialog.`
)}
>
<SelectOption
value="at-startup"
label={t`Preload at startup (default)`}
/>
<SelectOption value="never" label={t`Don't preload`} />
</SelectField>
<SelectField
fullWidth
floatingLabelText={
<Trans>Unloading of scene resources</Trans>
}
value={sceneResourcesUnloading}
onChange={e => setSceneResourcesUnloading(e.target.value)}
>
<SelectOption
value="never"
label={t`Never unload (default)`}
/>
<SelectOption
value="at-scene-exit"
label={t`Unload at scene exit`}
/>
</SelectField>
<ExtensionsProperties project={project} />
</ColumnStackLayout>
)}

View File

@@ -27,7 +27,7 @@ export default class BehaviorSharedPropertiesEditor extends React.Component<Prop
);
return (
<Column expand>
<Column expand noMargin>
{propertiesSchema.length ? (
<PropertiesEditor
schema={propertiesSchema}

View File

@@ -1,9 +1,9 @@
// @flow
import { Trans } from '@lingui/macro';
import { Trans, t } from '@lingui/macro';
import React from 'react';
import TextField from '../UI/TextField';
import RaisedButton from '../UI/RaisedButton';
import FlatButton from '../UI/FlatButton';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import ColorField from '../UI/ColorField';
import EmptyMessage from '../UI/EmptyMessage';
@@ -26,6 +26,9 @@ import { IconContainer } from '../UI/IconContainer';
import { getBehaviorTutorialIds } from '../Utils/GDevelopServices/Tutorial';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import SceneVariable from '../UI/CustomSvgIcons/SceneVariable';
import SelectOption from '../UI/SelectOption';
import SelectField from '../UI/SelectField';
import Text from '../UI/Text';
const gd: libGDevelop = global.gd;
@@ -57,6 +60,14 @@ const ScenePropertiesDialog = ({
shouldStopSoundsOnStartup,
setShouldStopSoundsOnStartup,
] = React.useState<boolean>(layout.stopSoundsOnStartup());
const [resourcesPreloading, setResourcesPreloading] = React.useState<string>(
layout.getResourcesPreloading()
);
const [resourcesUnloading, setResourcesUnloading] = React.useState<string>(
layout.getResourcesUnloading()
);
const [backgroundColor, setBackgroundColor] = React.useState<?RGBColor>({
r: layout.getBackgroundColorRed(),
g: layout.getBackgroundColorGreen(),
@@ -69,6 +80,8 @@ const ScenePropertiesDialog = ({
if (open && layout) {
setWindowTitle(layout.getWindowDefaultTitle());
setShouldStopSoundsOnStartup(layout.stopSoundsOnStartup());
setResourcesPreloading(layout.getResourcesPreloading());
setResourcesUnloading(layout.getResourcesUnloading());
setBackgroundColor({
r: layout.getBackgroundColorRed(),
g: layout.getBackgroundColorGreen(),
@@ -82,7 +95,8 @@ const ScenePropertiesDialog = ({
const onSubmit = () => {
layout.setWindowDefaultTitle(windowTitle);
layout.setStopSoundsOnStartup(shouldStopSoundsOnStartup);
layout.setResourcesPreloading(resourcesPreloading);
layout.setResourcesUnloading(resourcesUnloading);
layout.setBackgroundColor(
backgroundColor ? backgroundColor.r : 0,
backgroundColor ? backgroundColor.g : 0,
@@ -112,6 +126,11 @@ const ScenePropertiesDialog = ({
.getAllBehaviorSharedDataNames()
.toJSArray();
const areAdvancedPropertiesModified =
resourcesPreloading !== 'inherit' ||
resourcesUnloading !== 'inherit' ||
windowTitle !== '';
const propertiesEditors = allBehaviorSharedDataNames
.map(behaviorName => {
const behaviorSharedData = layout.getBehaviorSharedData(behaviorName);
@@ -143,6 +162,7 @@ const ScenePropertiesDialog = ({
key={behaviorName}
defaultExpanded
id={`behavior-parameters-${behaviorName}`}
noMargin
>
<AccordionHeader
actions={[
@@ -152,6 +172,7 @@ const ScenePropertiesDialog = ({
helpPagePath={behaviorMetadata.getHelpPath()}
/>,
]}
noMargin
>
{iconUrl ? (
<IconContainer
@@ -171,7 +192,7 @@ const ScenePropertiesDialog = ({
/>
</Column>
</AccordionHeader>
<AccordionBody>
<AccordionBody disableGutters>
<Column
expand
noMargin
@@ -228,20 +249,6 @@ const ScenePropertiesDialog = ({
maxWidth="sm"
>
<ColumnStackLayout expand noMargin>
<TextField
floatingLabelText={<Trans>Window title</Trans>}
fullWidth
type="text"
value={windowTitle}
onChange={(e, value) => setWindowTitle(value)}
/>
<Checkbox
checked={shouldStopSoundsOnStartup}
label={
<Trans>Stop music and sounds at the beginning of this scene</Trans>
}
onCheck={(e, check) => setShouldStopSoundsOnStartup(check)}
/>
<ColorField
floatingLabelText={<Trans>Scene background color</Trans>}
fullWidth
@@ -251,18 +258,76 @@ const ScenePropertiesDialog = ({
setBackgroundColor(rgbStringAndAlphaToRGBColor(color))
}
/>
<Checkbox
checked={shouldStopSoundsOnStartup}
label={
<Trans>Stop music and sounds at the beginning of this scene</Trans>
}
onCheck={(e, check) => setShouldStopSoundsOnStartup(check)}
/>
{!some(propertiesEditors) && (
<EmptyMessage>
<Trans>
Any additional properties will appear here if you add behaviors to
objects, like Physics behavior.
Additional properties will appear here when you add behaviors to
objects, such as the 2D or 3D Physics Engine.
</Trans>
</EmptyMessage>
)}
{propertiesEditors}
<Accordion defaultExpanded={areAdvancedPropertiesModified} noMargin>
<AccordionHeader noMargin>
<Text size="sub-title">
<Trans>Advanced settings</Trans>
</Text>
</AccordionHeader>
<AccordionBody disableGutters>
<ColumnStackLayout expand noMargin>
<SelectField
floatingLabelText={<Trans>Resources preloading</Trans>}
fullWidth
value={resourcesPreloading}
onChange={e => setResourcesPreloading(e.target.value)}
>
<SelectOption
value="inherit"
label={t`Use the project setting`}
/>
<SelectOption
value="at-startup"
label={t`Always preload at startup`}
/>
<SelectOption value="never" label={t`Never preload`} />
</SelectField>
<SelectField
floatingLabelText={<Trans>Resources unloading</Trans>}
fullWidth
value={resourcesUnloading}
onChange={e => setResourcesUnloading(e.target.value)}
>
<SelectOption
value="inherit"
label={t`Use the project setting`}
/>
<SelectOption
value="at-scene-exit"
label={t`Unload at scene exit`}
/>
<SelectOption value="never" label={t`Never unload`} />
</SelectField>
<TextField
floatingLabelText={<Trans>Window title</Trans>}
fullWidth
type="text"
value={windowTitle}
onChange={(e, value) => setWindowTitle(value)}
/>
</ColumnStackLayout>
</AccordionBody>
</Accordion>
{onOpenMoreSettings && (
<RaisedButton
label={<Trans>Open advanced settings</Trans>}
<FlatButton
label={<Trans>Open more settings</Trans>}
primary
fullWidth
onClick={() => {
if (onOpenMoreSettings) onOpenMoreSettings();

File diff suppressed because it is too large Load Diff