Files
GDevelop/GDJS/Runtime/ResourceLoader.ts
T
2023-12-07 16:21:12 +01:00

656 lines
20 KiB
TypeScript

/*
* 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/')
);
};
const maxForegroundConcurrency = 20;
const maxBackgroundConcurrency = 5;
const maxAttempt = 3;
/**
* 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;
/**
* It's set to `true` during intermediary loading screen to use a greater
* concurrency as the game is paused and doesn't need bandwidth (for video
* or music streaming or online multiplayer).
*/
private _isLoadingInForeground = true;
/**
* @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 processAndRetryIfNeededWithPromisePool(
[...this._resources.values()],
maxForegroundConcurrency,
maxAttempt,
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 processAndRetryIfNeededWithPromisePool(
resources,
maxForegroundConcurrency,
maxAttempt,
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 processAndRetryIfNeededWithPromisePool(
[...sceneResources.values()],
this._isLoadingInForeground
? maxForegroundConcurrency
: maxBackgroundConcurrency,
maxAttempt,
async (resourceName) => {
const resource = this._resources.get(resourceName);
if (!resource) {
logger.warn('Unable to find resource "' + resourceName + '".');
return;
}
await this._loadResource(resource);
loadedCount++;
this.currentSceneLoadingProgress = loadedCount / 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> {
this._isLoadingInForeground = true;
const task = this._prioritizeScene(sceneName);
return new Promise<void>((resolve, reject) => {
if (!task) {
this._isLoadingInForeground = false;
resolve();
return;
}
task.registerCallback(() => {
this._isLoadingInForeground = false;
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;
}
}
type PromiseError<T> = { item: T; error: Error };
type PromisePoolOutput<T, U> = {
results: Array<U>;
errors: Array<PromiseError<T>>;
};
const processWithPromisePool = <T, U>(
items: Array<T>,
maxConcurrency: number,
asyncFunction: (item: T) => Promise<U>
): Promise<PromisePoolOutput<T, U>> => {
const results: Array<U> = [];
const errors: Array<PromiseError<T>> = [];
let activePromises = 0;
let index = 0;
return new Promise((resolve, reject) => {
const executeNext = () => {
if (items.length === 0) {
resolve({ results, errors });
return;
}
while (activePromises < maxConcurrency && index < items.length) {
const item = items[index++];
activePromises++;
asyncFunction(item)
.then((result) => results.push(result))
.catch((error) => errors.push({ item, error }))
.finally(() => {
activePromises--;
if (index === items.length && activePromises === 0) {
resolve({ results, errors });
} else {
executeNext();
}
});
}
};
executeNext();
});
};
const processAndRetryIfNeededWithPromisePool = async <T, U>(
items: Array<T>,
maxConcurrency: number,
maxAttempt: number,
asyncFunction: (item: T) => Promise<U>
): Promise<PromisePoolOutput<T, U>> => {
const output = await processWithPromisePool<T, U>(
items,
maxConcurrency,
asyncFunction
);
if (output.errors.length !== 0) {
logger.warn("Some assets couldn't be downloaded. Trying again now.");
}
for (
let attempt = 1;
attempt < maxAttempt && output.errors.length !== 0;
attempt++
) {
const retryOutput = await processWithPromisePool<T, U>(
items,
maxConcurrency,
asyncFunction
);
output.results.push.apply(output.results, retryOutput.results);
output.errors = retryOutput.errors;
}
return output;
};
}