Files
GDevelop/GDJS/Runtime/runtimegame.ts
2025-03-28 17:54:28 +01:00

1475 lines
48 KiB
TypeScript

/*
* GDevelop JS Platform
* Copyright 2013-2016 Florian Rival (Florian.Rival@gmail.com). All rights reserved.
* This project is released under the MIT License.
*/
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. */
path: string;
/** The hash of the script file content. */
hash: number;
};
const getGlobalResourceNames = (projectData: ProjectData): Array<string> =>
projectData.usedResources.map((resource) => resource.name);
let supportedCompressionMethods: ('cs:gzip' | 'cs:deflate')[] | null = null;
const getSupportedCompressionMethods = (): ('cs:gzip' | 'cs:deflate')[] => {
if (!!supportedCompressionMethods) {
return supportedCompressionMethods;
}
supportedCompressionMethods = [];
try {
// @ts-ignore - We are checking if the CompressionStream is available.
new CompressionStream('gzip');
supportedCompressionMethods.push('cs:gzip');
} catch (e) {}
try {
// @ts-ignore - We are checking if the CompressionStream is available.
new CompressionStream('deflate');
supportedCompressionMethods.push('cs:deflate');
} catch (e) {}
return supportedCompressionMethods;
};
/** Options given to the game at startup. */
export type RuntimeGameOptions = {
/** if true, force fullscreen. */
forceFullscreen?: boolean;
/** if true, game is run as a preview launched from an editor. */
isPreview?: boolean;
/** The name of the external layout to create in the scene at position 0;0. */
injectExternalLayout?: string;
/** Script files, used for hot-reloading. */
scriptFiles?: Array<RuntimeGameOptionsScriptFile>;
/** if true, export is a partial preview without events. */
projectDataOnlyExport?: boolean;
/** if true, preview is launched from GDevelop native mobile app. */
nativeMobileApp?: boolean;
/** The address of the debugger server, to reach out using WebSocket. */
websocketDebuggerServerAddress?: string;
/** The port of the debugger server, to reach out using WebSocket. */
websocketDebuggerServerPort?: string;
/**
* The path to require `@electron/remote` module.
* This is only useful in a preview, where this can't be required from
* `@electron/remote` directly as previews don't have any node_modules.
* On the contrary, a game packaged with Electron as a standalone app
* has its node_modules.
* This can be removed once there are no more dependencies on
* `@electron/remote` in the game engine and extensions.
*/
electronRemoteRequirePath?: string;
/**
* The token to use by the game engine when requiring any resource stored on
* GDevelop Cloud buckets. Note that this is only useful during previews.
*/
gdevelopResourceToken?: string;
/**
* Check if, in some exceptional cases, we allow authentication
* to be done through a iframe.
* This is usually discouraged as the user can't verify that the authentication
* window is a genuine one. It's only to be used in trusted contexts.
*/
allowAuthenticationUsingIframeForPreview?: boolean;
/** If set, the game will send crash reports to GDevelop APIs. */
crashReportUploadLevel?: 'all' | 'exclude-javascript-code-events' | 'none';
/** Arbitrary string explaining in which context the game is being played. */
previewContext?: string;
/** The GDevelop version used to build the game. */
gdevelopVersionWithHash?: string;
/** The template slug that was used to create the project. */
projectTemplateSlug?: string;
/** The source game id that was used to create the project. */
sourceGameId?: string;
/** Any capture that should be done during the preview. */
captureOptions?: CaptureOptions;
/** Message to display to the user during an in-app tutorial. */
inAppTutorialMessageInPreview?: string;
inAppTutorialMessagePositionInPreview?: string;
/**
* If set, this data is used to authenticate automatically when launching the game.
* This is only useful during previews.
*/
playerUsername?: string;
playerId?: string;
playerToken?: string;
/**
* If set, the game should use the specified environment for making calls
* to GDevelop APIs ("dev" = development APIs).
*/
environment?: 'dev';
};
/**
* Represents a game being played.
*/
export class RuntimeGame {
_resourcesLoader: gdjs.ResourceLoader;
_variables: VariablesContainer;
_variablesByExtensionName: Map<string, gdjs.VariablesContainer>;
_data: ProjectData;
_sceneAndExtensionsData: Array<SceneAndExtensionsData> = [];
_eventsBasedObjectDatas: Map<String, EventsBasedObjectData>;
_effectsManager: EffectsManager;
_maxFPS: integer;
_minFPS: integer;
_gameResolutionWidth: integer;
_gameResolutionHeight: integer;
_originalWidth: float;
_originalHeight: float;
_resizeMode: 'adaptWidth' | 'adaptHeight' | string;
_adaptGameResolutionAtRuntime: boolean;
_scaleMode: ScaleMode;
_pixelsRounding: boolean;
_antialiasingMode: 'none' | 'MSAA';
_isAntialisingEnabledOnMobile: boolean;
/**
* Game loop management (see startGameLoop method)
*/
_renderer: RuntimeGameRenderer;
_sessionId: string | null;
_playerId: string | null;
_watermark: watermark.RuntimeWatermark;
_sceneStack: SceneStack;
/**
* When set to true, the scenes are notified that game resolution size changed.
*/
_notifyScenesForGameResolutionResize: boolean = false;
/**
* When set to true, the scenes are notified that game zoom factor changed.
*/
_notifyScenesForGameZoomFactorChange: boolean = false;
_zoomFactor: number = 1;
/**
* When paused, the game won't step and will be freezed. Useful for debugging.
*/
_paused: boolean = false;
/**
* True during the first frame the game is back from being hidden.
* This has nothing to do with `_paused`.
*/
_hasJustResumed: boolean = false;
//Inputs :
_inputManager: InputManager;
/**
* Allow to specify an external layout to insert in the first scene.
*/
_injectExternalLayout: any;
_options: RuntimeGameOptions;
/**
* The mappings for embedded resources
*/
_embeddedResourcesMappings: Map<string, Record<string, string>>;
/**
* Optional client to connect to a debugger server.
*/
_debuggerClient: gdjs.AbstractDebuggerClient | null;
_sessionMetricsInitialized: boolean = false;
_disableMetrics: boolean = false;
_isPreview: boolean;
/**
* The capture manager, used to manage captures (screenshots, videos, etc...).
*/
_captureManager: CaptureManager | null;
/** True if the RuntimeGame has been disposed and should not be used anymore. */
_wasDisposed: boolean = false;
/**
* @param data The object (usually stored in data.json) containing the full project data
* @param options The game options
*/
constructor(data: ProjectData, options?: RuntimeGameOptions) {
this._options = options || {};
this._variables = new gdjs.VariablesContainer(data.variables);
this._variablesByExtensionName = new Map<
string,
gdjs.VariablesContainer
>();
for (const extensionData of data.eventsFunctionsExtensions) {
if (extensionData.globalVariables.length > 0) {
this._variablesByExtensionName.set(
extensionData.name,
new gdjs.VariablesContainer(extensionData.globalVariables)
);
}
}
this._eventsBasedObjectDatas = new Map<String, EventsBasedObjectData>();
this._data = data;
this._updateSceneAndExtensionsData();
this._resourcesLoader = new gdjs.ResourceLoader(
this,
data.resources.resources,
getGlobalResourceNames(data),
data.layouts
);
this._effectsManager = new gdjs.EffectsManager();
this._maxFPS = this._data.properties.maxFPS;
this._minFPS = this._data.properties.minFPS;
this._gameResolutionWidth = this._data.properties.windowWidth;
this._gameResolutionHeight = this._data.properties.windowHeight;
this._originalWidth = this._gameResolutionWidth;
this._originalHeight = this._gameResolutionHeight;
this._resizeMode = this._data.properties.sizeOnStartupMode;
this._adaptGameResolutionAtRuntime =
this._data.properties.adaptGameResolutionAtRuntime;
this._scaleMode = data.properties.scaleMode || 'linear';
this._pixelsRounding = this._data.properties.pixelsRounding;
this._antialiasingMode = this._data.properties.antialiasingMode;
this._isAntialisingEnabledOnMobile =
this._data.properties.antialisingEnabledOnMobile;
this._renderer = new gdjs.RuntimeGameRenderer(
this,
this._options.forceFullscreen || false
);
this._watermark = new gdjs.watermark.RuntimeWatermark(
this,
data.properties.authorUsernames,
this._data.properties.watermark
);
this._sceneStack = new gdjs.SceneStack(this);
this._inputManager = new gdjs.InputManager();
this._injectExternalLayout = this._options.injectExternalLayout || '';
this._debuggerClient = gdjs.DebuggerClient
? new gdjs.DebuggerClient(this)
: null;
this._captureManager = gdjs.CaptureManager
? new gdjs.CaptureManager(
this._renderer,
this._options.captureOptions || {}
)
: null;
this._isPreview = this._options.isPreview || false;
this._sessionId = null;
this._playerId = null;
this._embeddedResourcesMappings = new Map();
for (const resource of this._data.resources.resources) {
if (resource.metadata) {
try {
const metadata = JSON.parse(resource.metadata);
if (metadata?.embeddedResourcesMapping) {
this._embeddedResourcesMappings.set(
resource.name,
metadata.embeddedResourcesMapping
);
}
} catch {
logger.error(
'Some metadata of resources can not be successfully parsed.'
);
}
}
}
if (this.isUsingGDevelopDevelopmentEnvironment()) {
logger.info(
'This game will run on the development version of GDevelop APIs.'
);
}
}
/**
* Update the project data. Useful for hot-reloading, should not be used otherwise.
*
* @param projectData The object (usually stored in data.json) containing the full project data
*/
setProjectData(projectData: ProjectData): void {
this._data = projectData;
this._updateSceneAndExtensionsData();
this._resourcesLoader.setResources(
projectData.resources.resources,
getGlobalResourceNames(projectData),
projectData.layouts
);
}
private _updateSceneAndExtensionsData(): void {
const usedExtensionsWithVariablesData =
this._data.eventsFunctionsExtensions.filter(
(extensionData) => extensionData.sceneVariables.length > 0
);
this._sceneAndExtensionsData = this._data.layouts.map((sceneData) => ({
sceneData,
usedExtensionsWithVariablesData,
}));
this._eventsBasedObjectDatas.clear();
if (this._data.eventsFunctionsExtensions) {
for (const extension of this._data.eventsFunctionsExtensions) {
for (const eventsBasedObject of extension.eventsBasedObjects) {
this._eventsBasedObjectDatas.set(
extension.name + '::' + eventsBasedObject.name,
eventsBasedObject
);
}
}
}
}
/**
* Return the additional options passed to the RuntimeGame when created.
* @returns The additional options, if any.
*/
getAdditionalOptions(): RuntimeGameOptions {
return this._options;
}
getRenderer(): gdjs.RuntimeGameRenderer {
return this._renderer;
}
/**
* Get the variables of the RuntimeGame.
* @return The global variables
*/
getVariables(): gdjs.VariablesContainer {
return this._variables;
}
/**
* Get the extension's global variables.
* @param extensionName The extension name.
* @returns The extension's global variables.
*/
getVariablesForExtension(extensionName: string) {
return this._variablesByExtensionName.get(extensionName) || null;
}
/**
* Get the gdjs.SoundManager of the RuntimeGame.
* @return The sound manager.
*/
getSoundManager(): gdjs.HowlerSoundManager {
return this._resourcesLoader.getSoundManager();
}
/**
* Get the gdjs.ImageManager of the RuntimeGame.
* @return The image manager.
*/
getImageManager(): gdjs.PixiImageManager {
return this._resourcesLoader.getImageManager();
}
/**
* Get the gdjs.FontManager of the RuntimeGame.
* @return The font manager.
*/
getFontManager(): gdjs.FontFaceObserverFontManager {
return this._resourcesLoader.getFontManager();
}
/**
* Get the gdjs.BitmapFontManager of the RuntimeGame.
* @return The bitmap font manager.
*/
getBitmapFontManager(): gdjs.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();
}
/**
* Get the Spine manager of the game, used to load and construct spine skeletons from game
* resources.
* @return The Spine manager for the game
*/
getSpineManager(): gdjs.SpineManager | null {
return this._resourcesLoader.getSpineManager();
}
/**
* Get the Spine Atlas manager of the game, used to load atlases from game
* resources.
* @return The Spine Atlas manager for the game
*/
getSpineAtlasManager(): gdjs.SpineAtlasManager | null {
return this._resourcesLoader.getSpineAtlasManager();
}
/**
* Get the input manager of the game, storing mouse, keyboard
* and touches states.
* @return The input manager owned by the game
*/
getInputManager(): gdjs.InputManager {
return this._inputManager;
}
/**
* Get the effects manager of the game, which allows to manage
* effects on runtime objects or runtime layers.
* @return The effects manager for the game
*/
getEffectsManager(): gdjs.EffectsManager {
return this._effectsManager;
}
/**
* Get the object containing the game data
* @return The object associated to the game.
*/
getGameData(): ProjectData {
return this._data;
}
getEventsBasedObjectData(type: string): EventsBasedObjectData | null {
const eventsBasedObjectData = this._eventsBasedObjectDatas.get(type);
if (!eventsBasedObjectData) {
logger.error(
'The game has no events-based object of the type "' + type + '"'
);
return null;
}
return eventsBasedObjectData;
}
/**
* Get the data associated to a scene.
*
* @param sceneName The name of the scene. If not defined, the first scene will be returned.
* @return The data associated to the scene.
*/
getSceneAndExtensionsData(
sceneName?: string
): SceneAndExtensionsData | null {
for (let i = 0, len = this._sceneAndExtensionsData.length; i < len; ++i) {
const sceneAndExtensionsData = this._sceneAndExtensionsData[i];
if (
sceneName === undefined ||
sceneAndExtensionsData.sceneData.name === sceneName
) {
return sceneAndExtensionsData;
}
}
logger.error('The game has no scene called "' + sceneName + '"');
return null;
}
/**
* Check if a scene exists
*
* @param sceneName The name of the scene to search.
* @return true if the scene exists. If sceneName is undefined, true if the game has a scene.
*/
hasScene(sceneName?: string): boolean {
for (let i = 0, len = this._data.layouts.length; i < len; ++i) {
const sceneData = this._data.layouts[i];
if (sceneName === undefined || sceneData.name == sceneName) {
return true;
}
}
return false;
}
/**
* Get the data associated to an external layout.
*
* @param name The name of the external layout.
* @return The data associated to the external layout or null if not found.
*/
getExternalLayoutData(name: string): ExternalLayoutData | null {
let externalLayout: ExternalLayoutData | null = null;
for (let i = 0, len = this._data.externalLayouts.length; i < len; ++i) {
const layoutData = this._data.externalLayouts[i];
if (layoutData.name === name) {
externalLayout = layoutData;
break;
}
}
return externalLayout;
}
/**
* Get the data representing all the global objects of the game.
* @return The data associated to the global objects.
*/
getInitialObjectsData(): ObjectData[] {
return this._data.objects || [];
}
/**
* Get the original width of the game, as set on the startup of the game.
*
* This is guaranteed to never change, even if the size of the game is changed afterwards.
*/
getOriginalWidth(): float {
return this._originalWidth;
}
/**
* Get the original height of the game, as set on the startup of the game.
*
* This is guaranteed to never change, even if the size of the game is changed afterwards.
*/
getOriginalHeight(): float {
return this._originalHeight;
}
/**
* Get the game resolution width for events.
* @returns The game resolution width, in pixels.
*/
getGameResolutionWidth(): float {
return this._gameResolutionWidth / this._zoomFactor;
}
/**
* Get the game resolution height for events.
* @returns The game resolution height, in pixels.
*/
getGameResolutionHeight(): float {
return this._gameResolutionHeight / this._zoomFactor;
}
/**
* Get the game resolution width (the size at which the game is rendered).
* @returns The game resolution width, in pixels.
*/
getRenderingResolutionWidth(): float {
return this._gameResolutionWidth;
}
/**
* Get the game resolution height (the size at which the game is rendered).
* @returns The game resolution height, in pixels.
*/
getRenderingResolutionHeight(): float {
return this._gameResolutionHeight;
}
/**
* The scale is usually near 1 unless the 'magnified' scale mode is used.
* @returns the factor between game resolution size and rendering resolution size.
*/
getZoomFactor() {
return this._zoomFactor;
}
/**
* Change the game resolution.
*
* @param width The new width
* @param height The new height
*/
setGameResolutionSize(width: float, height: float): void {
this._throwIfDisposed();
this._gameResolutionWidth = width;
this._gameResolutionHeight = height;
if (
gdjs.RuntimeGameRenderer &&
gdjs.RuntimeGameRenderer.getWindowInnerWidth &&
gdjs.RuntimeGameRenderer.getWindowInnerHeight
) {
const windowInnerWidth = gdjs.RuntimeGameRenderer.getWindowInnerWidth();
const windowInnerHeight =
gdjs.RuntimeGameRenderer.getWindowInnerHeight();
if (this._adaptGameResolutionAtRuntime) {
// Enlarge either the width or the eight to fill the inner window space.
if (this._resizeMode === 'adaptWidth') {
this._gameResolutionWidth =
(this._gameResolutionHeight * windowInnerWidth) /
windowInnerHeight;
} else if (this._resizeMode === 'adaptHeight') {
this._gameResolutionHeight =
(this._gameResolutionWidth * windowInnerHeight) /
windowInnerWidth;
} else if (this._resizeMode === 'scaleOuter') {
const widthFactor = windowInnerWidth / this._originalWidth;
const heightFactor = windowInnerHeight / this._originalHeight;
if (widthFactor < heightFactor) {
this._gameResolutionWidth = this._originalWidth;
this._gameResolutionHeight = Math.floor(
windowInnerHeight / widthFactor
);
} else {
this._gameResolutionWidth = Math.floor(
windowInnerWidth / heightFactor
);
this._gameResolutionHeight = this._originalHeight;
}
}
}
if (
this._scaleMode === 'magnified' &&
this._gameResolutionWidth > 0 &&
this._gameResolutionHeight > 0 &&
// Fall back on linear if magnified is used on a high resolution game.
this._originalWidth <= 960 &&
this._originalHeight <= 540
) {
const pixelSize = Math.max(
1,
Math.ceil(
this._zoomFactor *
Math.min(
windowInnerWidth / this._gameResolutionWidth,
windowInnerHeight / this._gameResolutionHeight
)
)
);
this._gameResolutionWidth = Math.round(
(this._gameResolutionWidth * pixelSize) / this._zoomFactor
);
this._gameResolutionHeight = Math.round(
(this._gameResolutionHeight * pixelSize) / this._zoomFactor
);
if (this._zoomFactor !== pixelSize && pixelSize >= 1) {
this._zoomFactor = pixelSize;
this._notifyScenesForGameZoomFactorChange = true;
}
}
}
// Don't alter the game resolution. The renderer
// will maybe adapt the size of the canvas or whatever is used to render the
// game in the window, but this does not change the "game resolution".
// Notify the renderer that game resolution changed (so that the renderer size
// can be updated, and maybe other things like the canvas size), and let the
// scenes know too.
this._renderer.updateRendererSize();
this._notifyScenesForGameResolutionResize = true;
}
/**
* Set if the width or the height of the game resolution
* should be changed to fit the game window - or if the game
* resolution should not be updated automatically.
*
* @param resizeMode Either "" (don't change game resolution), "adaptWidth" or "adaptHeight".
*/
setGameResolutionResizeMode(resizeMode: string): void {
this._resizeMode = resizeMode;
this._forceGameResolutionUpdate();
}
/**
* Returns if the width or the height of the game resolution
* should be changed to fit the game window - or if the game
* resolution should not be updated automatically (empty string).
*
* @returns Either "" (don't change game resolution), "adaptWidth" or "adaptHeight".
*/
getGameResolutionResizeMode(): string {
return this._resizeMode;
}
/**
* Set if the game resolution should be automatically adapted
* when the game window or screen size change. This will only
* be the case if the game resolution resize mode is
* configured to adapt the width or the height of the game.
* @param enable true to change the game resolution according to the window/screen size.
*/
setAdaptGameResolutionAtRuntime(enable: boolean): void {
this._adaptGameResolutionAtRuntime = enable;
this._forceGameResolutionUpdate();
}
/**
* Returns if the game resolution should be automatically adapted
* when the game window or screen size change. This will only
* be the case if the game resolution resize mode is
* configured to adapt the width or the height of the game.
* @returns true if the game resolution is automatically changed according to the window/screen size.
*/
getAdaptGameResolutionAtRuntime(): boolean {
return this._adaptGameResolutionAtRuntime;
}
/**
* Return the minimal fps that must be guaranteed by the game
* (otherwise, game is slowed down).
*/
getMinimalFramerate(): integer {
return this._minFPS;
}
/**
* Return the scale mode of the game ("linear", "magnified" or "nearest").
*/
getScaleMode(): ScaleMode {
return this._scaleMode;
}
/**
* Return if the game is rounding pixels when rendering.
*/
getPixelsRounding(): boolean {
return this._pixelsRounding;
}
/**
* Return the antialiasing mode used by the game ("none" or "MSAA").
*/
getAntialiasingMode(): 'none' | 'MSAA' {
return this._antialiasingMode;
}
/**
* Return true if antialising is enabled on mobiles.
*/
isAntialisingEnabledOnMobile(): boolean {
return this._isAntialisingEnabledOnMobile;
}
/**
* Set or unset the game as paused.
* When paused, the game won't step and will be freezed. Useful for debugging.
* @param enable true to pause the game, false to unpause
*/
pause(enable: boolean) {
if (this._paused === enable) return;
this._paused = enable;
if (this._debuggerClient) {
if (this._paused) this._debuggerClient.sendGamePaused();
else this._debuggerClient.sendGameResumed();
}
}
/**
* @returns true during the first frame the game is back from being hidden.
* This has nothing to do with `_paused`.
*/
hasJustResumed() {
return this._hasJustResumed;
}
/**
* 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._throwIfDisposed();
this.loadFirstAssetsAndStartBackgroundLoading(
this._getFirstSceneName(),
progressCallback
).then(callback);
}
/**
* 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.
*/
async loadFirstAssetsAndStartBackgroundLoading(
firstSceneName: string,
progressCallback?: (progress: float) => void
): Promise<void> {
try {
// Download the loading screen background image first to be able to
// display the loading screen as soon as possible.
const backgroundImageResourceName =
this._data.properties.loadingScreen.backgroundImageResourceName;
if (backgroundImageResourceName) {
await this._resourcesLoader
.getImageManager()
.loadResource(backgroundImageResourceName);
}
await Promise.all([
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
),
// TODO This is probably not necessary in case of hot reload.
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,
this._data.properties.watermark.showWatermark,
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
: // There is always at least a scene
this.getSceneAndExtensionsData()!.sceneData.name;
}
/**
* Start the game loop, to be called once assets are loaded.
*/
startGameLoop() {
this._throwIfDisposed();
try {
if (!this.hasScene()) {
logger.error('The game has no scene.');
return;
}
this._forceGameResolutionUpdate();
// Load the first scene
this._sceneStack.push(
this._getFirstSceneName(),
this._injectExternalLayout
);
this._watermark.displayAtStartup();
//Uncomment to profile the first x frames of the game.
// var x = 500;
// var startTime = Date.now();
// console.profile("Stepping for " + x + " frames")
// for(var i = 0; i < x; ++i) {
// this._sceneStack.step(16);
// }
// console.profileEnd();
// var time = Date.now() - startTime;
// logger.log("Took", time, "ms");
// return;
this._setupGameVisibilityEvents();
if (gdjs.inAppTutorialMessage) {
gdjs.inAppTutorialMessage.displayInAppTutorialMessage(
this,
this._options.inAppTutorialMessageInPreview,
this._options.inAppTutorialMessagePositionInPreview || ''
);
}
// The standard game loop
let accumulatedElapsedTime = 0;
this._hasJustResumed = false;
this._renderer.startGameLoop((lastCallElapsedTime) => {
try {
if (this._paused) {
return true;
}
// Skip the frame if we rendering frames too fast
accumulatedElapsedTime += lastCallElapsedTime;
if (
this._maxFPS > 0 &&
1000.0 / accumulatedElapsedTime > this._maxFPS + 7
) {
// Only skip frame if the framerate is 7 frames above the maximum framerate.
// Most browser/engines will try to run at slightly more than 60 frames per second.
// If game is set to have a maximum FPS to 60, then one out of two frames will be dropped.
// Hence, we use a 7 frames margin to ensure that we're not skipping frames too much.
return true;
}
const elapsedTime = accumulatedElapsedTime;
accumulatedElapsedTime = 0;
// Manage resize events.
if (this._notifyScenesForGameResolutionResize) {
this._sceneStack.onGameResolutionResized();
this._notifyScenesForGameResolutionResize = false;
}
if (this._notifyScenesForGameZoomFactorChange) {
this._sceneStack.onGameZoomFactorChanged();
this._notifyScenesForGameZoomFactorChange = false;
}
// Render and step the scene.
if (this._sceneStack.step(elapsedTime)) {
this.getInputManager().onFrameEnded();
this._hasJustResumed = false;
return true;
}
return false;
} catch (e) {
if (this._debuggerClient)
this._debuggerClient.onUncaughtException(e);
throw e;
}
});
setTimeout(() => {
this._setupSessionMetrics();
}, 4000);
if (this._captureManager) {
this._captureManager.setupCaptureOptions(this._isPreview);
}
} catch (e) {
if (this._debuggerClient) this._debuggerClient.onUncaughtException(e);
throw e;
}
}
/**
* Stop game loop, unload all scenes, dispose renderer and resources.
* After calling this method, the RuntimeGame should not be used anymore.
* @param removeCanvas If true, the canvas will be removed from the DOM.
*/
dispose(removeCanvas?: boolean): void {
this._renderer.stopGameLoop();
this._sceneStack.dispose();
this._renderer.dispose(removeCanvas);
this._resourcesLoader.dispose();
this._wasDisposed = true;
}
/**
* Set if the session should be registered.
*/
enableMetrics(enable: boolean): void {
this._disableMetrics = !enable;
if (enable) {
this._setupSessionMetrics();
}
}
/**
* Helper function to get information about the platform running the game.
*/
getPlatformInfo = () => {
return {
// @ts-ignore
isCordova: !!window.cordova,
devicePlatform:
// @ts-ignore
typeof device !== 'undefined' ? device.platform || '' : '',
navigatorPlatform:
typeof navigator !== 'undefined' ? navigator.platform : '',
hasTouch:
typeof navigator !== 'undefined'
? !!navigator.maxTouchPoints && navigator.maxTouchPoints > 2
: false,
supportedCompressionMethods: getSupportedCompressionMethods(),
};
};
_setupGameVisibilityEvents() {
if (typeof navigator !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this._hasJustResumed = true;
}
});
window.addEventListener(
'resume',
() => {
this._hasJustResumed = true;
},
false
);
}
}
/**
* Register a new session for the game, and set up listeners to follow the session
* time.
*/
_setupSessionMetrics() {
if (this._sessionMetricsInitialized) {
return;
}
if (this._disableMetrics) {
return;
}
if (this.isPreview()) {
return;
}
if (typeof fetch === 'undefined') {
return;
}
if (!this._data.properties.projectUuid) {
return;
}
const baseUrl = 'https://api.gdevelop-app.com/analytics';
this._playerId = this._makePlayerUuid();
/**
* The duration that is already sent to the service
* (in milliseconds).
**/
let sentDuration = 0;
/**
* The duration that is not yet sent to the service to avoid flooding
* (in milliseconds).
**/
let notYetSentDuration = 0;
/**
* The last time when duration has been counted
* either in sendedDuration or notYetSentDuration.
**/
let lastSessionResumeTime = Date.now();
const platform = this.getPlatformInfo();
fetch(baseUrl + '/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// It's important to ensure that the data sent here does not contain
// any personal information from the player or that would allow to
// precisely identify someone.
body: JSON.stringify({
gameId: this._data.properties.projectUuid,
playerId: this._playerId,
game: {
name: this._data.properties.name || '',
packageName: this._data.properties.packageName || '',
version: this._data.properties.version || '',
location: window.location.href,
},
platform: {
isCordova: platform.isCordova,
devicePlatform: platform.devicePlatform,
navigatorPlatform: platform.navigatorPlatform,
hasTouch: platform.hasTouch,
},
}),
})
.then((response) => {
// Ensure the session is correctly created to avoid sending hits that will fail.
if (!response.ok) {
console.error('Error while creating the session', response);
throw new Error('Error while creating the session');
}
return response;
})
.then((response) => response.text())
.then((returnedSessionId) => {
this._sessionId = returnedSessionId;
})
.catch(() => {});
/* Ignore any error */
const sendSessionHit = () => {
if (!this._sessionId) {
return;
}
const now = Date.now();
notYetSentDuration += now - lastSessionResumeTime;
lastSessionResumeTime = now;
// Group repeated calls to sendSessionHit - which could
// happen because of multiple event listeners being fired.
if (notYetSentDuration < 5 * 1000) {
return;
}
// The backend use seconds for duration.
// The milliseconds will stay in notYetSentDuration.
const toBeSentDuration = Math.floor(notYetSentDuration / 1000) * 1000;
sentDuration += toBeSentDuration;
notYetSentDuration -= toBeSentDuration;
navigator.sendBeacon(
baseUrl + '/session-hit',
JSON.stringify({
gameId: this._data.properties.projectUuid,
playerId: this._playerId,
sessionId: this._sessionId,
duration: Math.floor(sentDuration / 1000),
})
);
};
if (typeof navigator !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// Skip the duration the game was hidden.
lastSessionResumeTime = Date.now();
} else {
sendSessionHit();
}
});
window.addEventListener('pagehide', sendSessionHit, false);
// Cordova events
window.addEventListener('pause', sendSessionHit, false);
window.addEventListener(
'resume',
() => {
// Skip the duration the game was hidden.
lastSessionResumeTime = Date.now();
},
false
);
// Detect Safari to work around Safari-specific bugs:
// - https://bugs.webkit.org/show_bug.cgi?id=151610
// - https://bugs.webkit.org/show_bug.cgi?id=151234
// @ts-ignore
const isSafari = typeof safari === 'object' && safari.pushNotification;
const isElectron = /electron/i.test(navigator.userAgent);
if (isSafari || isElectron) {
window.addEventListener('beforeunload', () => {
sendSessionHit();
});
}
}
this._sessionMetricsInitialized = true;
this._sessionId = this._sessionId;
}
/**
* Generate an anonymous unique identifier to differentiate
* the player from others in the game metrics.
*/
_makePlayerUuid(): string {
try {
const key = 'GDJS-internal-player-uuid';
const existingPlayerUuid = localStorage.getItem(key);
if (existingPlayerUuid) {
return existingPlayerUuid;
}
const newPlayerUuid = gdjs.makeUuid();
localStorage.setItem(key, newPlayerUuid);
return newPlayerUuid;
} catch (err) {
return gdjs.makeUuid();
}
}
getSessionId(): string | null {
return this._sessionId;
}
getPlayerId(): string | null {
return this._playerId;
}
/**
* Called by the game renderer when the window containing the game
* has changed size (this can result from a resize of the window,
* but also other factors like a device orientation change on mobile).
*/
onWindowInnerSizeChanged() {
this._forceGameResolutionUpdate();
}
/**
* Enlarge/reduce the width (or the height) of the game to fill the inner window.
*/
private _forceGameResolutionUpdate() {
this.setGameResolutionSize(
this._gameResolutionWidth,
this._gameResolutionHeight
);
}
/**
* Start a profiler for the currently running scene.
* @param onProfilerStopped Function to be called when the profiler is stopped. Will be passed the profiler as argument.
*/
startCurrentSceneProfiler(
onProfilerStopped: (oldProfiler: Profiler) => void
) {
this._throwIfDisposed();
const currentScene = this._sceneStack.getCurrentScene();
if (!currentScene) {
return false;
}
currentScene.startProfiler(onProfilerStopped);
return true;
}
/**
* Stop the profiler for the currently running scene.
*/
stopCurrentSceneProfiler() {
this._throwIfDisposed();
const currentScene = this._sceneStack.getCurrentScene();
if (!currentScene) {
return;
}
currentScene.stopProfiler();
}
/**
* Return true if a scene was loaded, false otherwise (i.e: game not yet started).
*/
wasFirstSceneLoaded(): boolean {
return this._sceneStack.wasFirstSceneLoaded();
}
/**
* Return the stack of {@link gdjs.RuntimeScene} being played.
*/
getSceneStack(): gdjs.SceneStack {
return this._sceneStack;
}
/**
* Check if the game is running as a preview, launched from an editor.
* @returns true if the current game is a preview.
*/
isPreview(): boolean {
return this._isPreview;
}
/**
* Check if the game should call GDevelop development APIs or not.
*
* Unless you are contributing to GDevelop, avoid using this.
*/
isUsingGDevelopDevelopmentEnvironment(): boolean {
return this._options.environment === 'dev';
}
/**
* Gets an extension property from the project data.
* @param extensionName The extension name.
* @param propertyName The property name.
* @return The property value.
*/
getExtensionProperty(
extensionName: string,
propertyName: string
): string | null {
for (let property of this._data.properties.extensionProperties) {
if (
property.extension === extensionName &&
property.property === propertyName
) {
return property.value;
}
}
return null;
}
/**
* Resolves the name of an embedded resource.
* @param mainResourceName The name of the resource containing the embedded resource.
* @param embeddedResourceName The name of the embedded resource.
* @return The resource name.
*/
resolveEmbeddedResource(
mainResourceName: string,
embeddedResourceName: string
): string {
const mapping = this._embeddedResourcesMappings.get(mainResourceName);
return mapping && mapping[embeddedResourceName]
? mapping[embeddedResourceName]
: embeddedResourceName;
}
/**
* Returns the array of resources that are embedded to passed one.
* @param resourceName The name of resource to find embedded resources of.
* @returns The array of related resources names.
*/
getEmbeddedResourcesNames(resourceName: string): string[] {
return this._embeddedResourcesMappings.has(resourceName)
? Object.keys(this._embeddedResourcesMappings.get(resourceName)!)
: [];
}
getNetworkSyncData(
syncOptions: GetNetworkSyncDataOptions
): GameNetworkSyncData | null {
const syncData: GameNetworkSyncData = {
var: this._variables.getNetworkSyncData(syncOptions),
ss: this._sceneStack.getNetworkSyncData(syncOptions) || undefined,
};
const extensionsVariablesSyncData = {};
this._variablesByExtensionName.forEach((variables, extensionName) => {
const extensionVariablesSyncData =
variables.getNetworkSyncData(syncOptions);
// If there is no variables to sync, don't include the extension in the sync data.
if (extensionVariablesSyncData.length) {
extensionsVariablesSyncData[extensionName] =
extensionVariablesSyncData;
}
});
syncData.extVar = extensionsVariablesSyncData;
if (
(!syncData.var || syncData.var.length === 0) &&
!syncData.ss &&
(!syncData.extVar || Object.keys(syncData.extVar).length === 0)
) {
// Nothing to sync.
return null;
}
return syncData;
}
updateFromNetworkSyncData(syncData: GameNetworkSyncData) {
this._throwIfDisposed();
if (syncData.var) {
this._variables.updateFromNetworkSyncData(syncData.var);
}
if (syncData.ss) {
this._sceneStack.updateFromNetworkSyncData(syncData.ss);
}
if (syncData.extVar) {
for (const extensionName in syncData.extVar) {
if (!syncData.extVar.hasOwnProperty(extensionName)) {
continue;
}
const extensionVariablesData = syncData.extVar[extensionName];
const extensionVariables =
this.getVariablesForExtension(extensionName);
if (extensionVariables) {
extensionVariables.updateFromNetworkSyncData(
extensionVariablesData
);
}
}
}
}
private _throwIfDisposed(): void {
if (this._wasDisposed) {
throw 'The RuntimeGame has been disposed and should not be used anymore.';
}
}
}
}