/* * 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). * @category Core Engine > Game */ 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 => 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; }; /** * The desired status of the game, used for previews or in-game edition. * Either stored in the options generated by the preview or in the URL * in case of a hard reload. * @category Core Engine > Game */ export type RuntimeGameStatus = { isPaused: boolean; isInGameEdition: boolean; sceneName: string | null; injectedExternalLayoutName: string | null; skipCreatingInstancesFromScene: boolean; eventsBasedObjectType: string | null; eventsBasedObjectVariantName: string | null; editorId: string | null; editorCamera3D?: EditorCameraState; }; /** * Read the desired status of the game from the URL. Only useful for previews * when hard reloaded. */ const readRuntimeGameStatusFromUrl = (): RuntimeGameStatus | null => { try { const url = new URL(location.href); const runtimeGameStatus = url.searchParams.get('runtimeGameStatus'); if (!runtimeGameStatus) return null; const parsedRuntimeGameStatus = JSON.parse(runtimeGameStatus); return { isPaused: !!parsedRuntimeGameStatus.isPaused, isInGameEdition: !!parsedRuntimeGameStatus.isInGameEdition, sceneName: '' + parsedRuntimeGameStatus.sceneName, injectedExternalLayoutName: '' + parsedRuntimeGameStatus.injectedExternalLayoutName, skipCreatingInstancesFromScene: !!parsedRuntimeGameStatus.skipCreatingInstancesFromScene, eventsBasedObjectType: parsedRuntimeGameStatus.eventsBasedObjectType, eventsBasedObjectVariantName: parsedRuntimeGameStatus.eventsBasedObjectVariantName, editorId: parsedRuntimeGameStatus.editorId, editorCamera3D: parsedRuntimeGameStatus.editorCamera3D, }; } catch (e) { return null; } }; /** * Options given to the game at startup. * @category Core Engine > Game */ export type RuntimeGameOptions = { /** if true, force fullscreen. */ forceFullscreen?: boolean; /** if true, game is run as a preview launched from an editor. */ isPreview?: boolean; /** if set, the status of the game to be restored. */ initialRuntimeGameStatus?: RuntimeGameStatus; inGameEditorSettings?: InGameEditorSettings; /** Script files, used for hot-reloading. */ scriptFiles?: Array; /** if true, export is a partial preview without reloading libraries. */ shouldReloadLibraries?: boolean; /** if true, export is a partial preview without generating events. */ shouldGenerateScenesEventsCode?: 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. * @category Core Engine > Game */ export class RuntimeGame { _resourcesLoader: gdjs.ResourceLoader; _variables: VariablesContainer; _variablesByExtensionName: Map; _data: ProjectData; _sceneAndExtensionsData: Array = []; _eventsBasedObjectDatas: Map; _effectsManager: EffectsManager; _maxFPS: integer; _minFPS: integer; _gameResolutionWidth: integer; _gameResolutionHeight: integer; _originalWidth: float; _originalHeight: float; _resizeMode: | '' | 'scaleOuter' | 'adaptWidth' | 'adaptHeight' | 'native' | string; _adaptGameResolutionAtRuntime: boolean; _scaleMode: 'linear' | 'nearest'; _pixelsRounding: boolean; _antialiasingMode: 'none' | 'MSAA'; _isAntialisingEnabledOnMobile: boolean; /** * Game loop management (see startGameLoop method) */ _renderer: RuntimeGameRenderer; _displayedLoadingScreen: gdjs.LoadingScreenRenderer | null = null; _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 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 : private _inputManager: InputManager; _options: RuntimeGameOptions; /** * The mappings for embedded resources */ _embeddedResourcesMappings: Map>; _sceneResourcesPreloading: 'at-startup' | 'never'; _sceneResourcesUnloading: 'at-scene-exit' | 'never'; /** * Optional client to connect to a debugger server. */ _debuggerClient: gdjs.AbstractDebuggerClient | null; _sessionMetricsInitialized: boolean = false; _disableMetrics: boolean = false; _isPreview: boolean; _isInGameEdition: 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; _inGameEditor: InGameEditor | null; /** * @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._isPreview = this._options.isPreview || false; if (this._isPreview) { // Check if we need to restore the state from the URL, which is used // when a preview is hard reloaded (search for `hardReload`). const runtimeGameStatusFromUrl = readRuntimeGameStatusFromUrl(); if (runtimeGameStatusFromUrl) { this._options.initialRuntimeGameStatus = runtimeGameStatusFromUrl; } } this._isInGameEdition = this._options.initialRuntimeGameStatus?.isInGameEdition || false; 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(); this._data = data; this._updateSceneAndExtensionsData(); gdjs.Variable.useDeprecatedZeroAsDefaultStringVariable = !!data.properties.useDeprecatedZeroAsDefaultStringVariable; this._sceneResourcesPreloading = this._data.properties.sceneResourcesPreloading || 'at-startup'; this._sceneResourcesUnloading = this._data.properties.sceneResourcesUnloading || 'never'; this._resourcesLoader = new gdjs.ResourceLoader( this, data.resources.resources, getGlobalResourceNames(data), data.layouts ); this._inGameEditor = this._isInGameEdition ? new gdjs.InGameEditor( this, data, this._options.inGameEditorSettings || null ) : null; this._debuggerClient = gdjs.DebuggerClient ? new gdjs.DebuggerClient(this) : null; 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._captureManager = gdjs.CaptureManager ? new gdjs.CaptureManager( this._renderer, this._options.captureOptions || {} ) : null; this._sessionId = null; this._playerId = null; this._embeddedResourcesMappings = new Map(); this._updateEmbeddedResourcesMappings(); 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 { if (this._inGameEditor) { this._inGameEditor.onProjectDataChange(projectData); } this._data = projectData; this._updateEmbeddedResourcesMappings(); 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 ); } } } } private _updateEmbeddedResourcesMappings(): void { this._embeddedResourcesMappings.clear(); for (const resource of this._data.resources.resources) { if (!resource.metadata) continue; 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.' ); } } } /** * 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.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. */ 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; } getEventsBasedObjectVariantData( type: string, variantName: string ): EventsBasedObjectVariantData | null { const eventsBasedObjectData = this.getEventsBasedObjectData(type); if (!eventsBasedObjectData) { return null; } return gdjs.RuntimeGame._getEventsBasedObjectVariantData( eventsBasedObjectData, variantName ); } static _getEventsBasedObjectVariantData( eventsBasedObjectData: EventsBasedObjectData, variantName: string ): EventsBasedObjectVariantData { if (!eventsBasedObjectData.defaultVariant) { eventsBasedObjectData.defaultVariant = { ...eventsBasedObjectData, name: '', }; } // Legacy events-based objects don't have any instance in their default // variant since there wasn't a graphical editor at the time. // In this case, the editor doesn't allow to choose a variant, but a // variant may have stayed after a user rolled back the extension. // This variant must be ignored to match what the editor shows. const isForcedToOverrideEventsBasedObjectChildrenConfiguration = eventsBasedObjectData.defaultVariant.instances.length == 0; if (isForcedToOverrideEventsBasedObjectChildrenConfiguration) { return eventsBasedObjectData.defaultVariant; } let usedVariantData: EventsBasedObjectVariantData = eventsBasedObjectData.defaultVariant; for ( let variantIndex = 0; variantIndex < eventsBasedObjectData.variants.length; variantIndex++ ) { const variantData = eventsBasedObjectData.variants[variantIndex]; if (variantData.name === variantName) { usedVariantData = variantData; } } return usedVariantData; } /** * 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 a scene. * * @param name The name of the scene. * @return The data associated to the scene or null if not found. */ getSceneData(sceneName: string): LayoutData | null { for (let i = 0, len = this._data.layouts.length; i < len; ++i) { const sceneData = this._data.layouts[i]; if (sceneData.name == sceneName) { return sceneData; } } return null; } /** * 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 (the size at which the game is played and rendered) width. * @returns The game resolution width, in pixels. */ getGameResolutionWidth(): float { return this._gameResolutionWidth; } /** * Get the game resolution (the size at which the game is played and rendered) height. * @returns The game resolution height, in pixels. */ getGameResolutionHeight(): float { return this._gameResolutionHeight; } /** * 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 (this._adaptGameResolutionAtRuntime || this._isInGameEdition) { if ( gdjs.RuntimeGameRenderer && gdjs.RuntimeGameRenderer.getWindowInnerWidth && gdjs.RuntimeGameRenderer.getWindowInnerHeight ) { const windowInnerWidth = gdjs.RuntimeGameRenderer.getWindowInnerWidth(); const windowInnerHeight = gdjs.RuntimeGameRenderer.getWindowInnerHeight(); // Enlarge either the width or the eight to fill the inner window space. if (this._isInGameEdition) { this._gameResolutionWidth = windowInnerWidth; this._gameResolutionHeight = windowInnerHeight; } else 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; } } } } // 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" or "nearest"). */ getScaleMode(): 'linear' | 'nearest' { 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._inGameEditor) this._inGameEditor.activate(enable); if (this._debuggerClient) { this._debuggerClient.sendRuntimeGameStatus(); } } /** * @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 an object assets in background. */ loadObjectOrGroupAssets( objectOrGroupName: string, sceneName?: string ): void { const currentScene = this._sceneStack.getCurrentScene(); if (!currentScene) { return; } if (!sceneName) { sceneName = currentScene.getName(); } const objectGroupData = this.getObjectGroupData( sceneName, objectOrGroupName ); if (objectGroupData) { for (const object of objectGroupData.objects) { this._loadObjectAssets(sceneName, object.name); } } else { this._loadObjectAssets(sceneName, objectOrGroupName); } } private _loadObjectAssets(sceneName: string, objectName: string) { const objectData = this.getObjectData(sceneName, objectName); if (!objectData) { return; } const usedResources = objectData.usedResources; if (!usedResources) { return; } this._resourcesLoader.loadObjectResources( sceneName, objectName, usedResources ); } /** * @returns true when all the resources of the given object are loaded. */ areObjectOrGroupAssetsLoaded( objectOrGroupName: string, sceneName?: string ): boolean { const currentScene = this._sceneStack.getCurrentScene(); if (!currentScene) { return false; } if (!sceneName) { sceneName = currentScene.getName(); } const objectGroupData = this.getObjectGroupData( sceneName, objectOrGroupName ); if (objectGroupData) { for (const object of objectGroupData.objects) { if ( !this._resourcesLoader.areObjectAssetsReady(sceneName, object.name) ) { return false; } } return true; } return this._resourcesLoader.areObjectAssetsReady( sceneName, objectOrGroupName ); } /** * Unload an object assets. */ unloadObjectOrGroupAssets( objectOrGroupName: string, sceneName?: string ): void { const currentScene = this._sceneStack.getCurrentScene(); if (!currentScene) { return; } if (!sceneName) { sceneName = currentScene.getName(); } const objectGroupData = this.getObjectGroupData( sceneName, objectOrGroupName ); if (objectGroupData) { for (const object of objectGroupData.objects) { this._resourcesLoader.unloadObjectResources(sceneName, object.name); } } else { this._resourcesLoader.unloadObjectResources( sceneName, objectOrGroupName ); } } private getObjectData( sceneName: string, objectName: string ): ObjectData | null { const sceneData = this.getSceneData(sceneName); if (sceneData) { for (const objectData of sceneData.objects) { if (objectData.name === objectName) { return objectData; } } } return null; } private getObjectGroupData( sceneName: string, objectGroupName: string ): ObjectGroupData | null { const sceneData = this.getSceneData(sceneName); if (sceneData) { for (const objectGroupData of sceneData.objectsGroups) { if (objectGroupData.name === objectGroupName) { return objectGroupData; } } } return null; } /** * 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); } /** * 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. */ 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 { 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 as Error); throw e; } } /** * Load all assets for a given scene, displaying progress in renderer. */ async loadSceneAssets( sceneName: string, progressCallback?: (progress: float) => void ): Promise { 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 ) => Promise, progressCallback?: (progress: float) => void ): Promise { this.pause(true); const loadingScreen = new gdjs.LoadingScreenRenderer( this.getRenderer(), this._resourcesLoader.getImageManager(), this._data.properties.loadingScreen, this._data.properties.watermark.showWatermark, isFirstScene ); this._displayedLoadingScreen = loadingScreen; 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._displayedLoadingScreen = null; if (!this._isInGameEdition) { this.pause(false); } } private _getFirstSceneName(): string { const firstSceneName = this._options.initialRuntimeGameStatus?.sceneName || 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 const sceneName = this._getFirstSceneName(); const externalLayoutName = this._options.initialRuntimeGameStatus?.injectedExternalLayoutName || null; if (this._inGameEditor) { const eventsBasedObjectType = this._options.initialRuntimeGameStatus?.eventsBasedObjectType || null; const eventsBasedObjectVariantName = this._options.initialRuntimeGameStatus ?.eventsBasedObjectVariantName || null; const editorId = this._options.initialRuntimeGameStatus?.editorId || null; const editorCamera3D = this._options.initialRuntimeGameStatus?.editorCamera3D || null; this._inGameEditor.switchToSceneOrVariant( editorId, sceneName, externalLayoutName, eventsBasedObjectType, eventsBasedObjectVariantName, editorCamera3D ); } else { if (sceneName) { this.getSceneStack().replace({ sceneName, externalLayoutName: externalLayoutName === null ? undefined : externalLayoutName, clear: true, }); } } 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 lastFrameSceneName: string | null = null; let accumulatedElapsedTime = 0; this._hasJustResumed = false; this._renderer.startGameLoop((lastCallElapsedTime) => { try { // Watch the scene name to automatically update debugger when a scene is changed. if (this._debuggerClient) { const currentScene = ( this._inGameEditor || this.getSceneStack() ).getCurrentScene(); if ( currentScene && currentScene.getName() !== lastFrameSceneName ) { lastFrameSceneName = currentScene.getName(); this._debuggerClient.sendRuntimeGameStatus(); } } // If the game is edited, update the target framerate according to interactions. // Do it now (before frame skip), so that if a user interaction happens // we don't wait for a frame to pass at the current, probably very slow framerate. if (this._paused && this._inGameEditor) { this._inGameEditor.updateTargetFramerate(lastCallElapsedTime); } // 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) { if (this._inGameEditor) { this._inGameEditor.onGameResolutionResized(); } else { this._sceneStack.onGameResolutionResized(); } this._notifyScenesForGameResolutionResize = false; } // Render and possibly step the game. if (this._paused) { if (this._inGameEditor) { if (this._displayedLoadingScreen) { // Nothing to do, the loading screen is rendering itself by // having renderIfNeeded called when there is some progress, // and will directly call into the game renderer. } else { // The game is paused for edition: the in-game editor runs and render // the scene. this._inGameEditor.updateAndRender(); } } else { // The game is paused (for debugging): the rendering of the scene is done, // but the game logic is not executed (no full "step"). // Note we might want to disable rendering if there is a loading screen? this._sceneStack.renderWithoutStep(); } } else { // The game is not paused (and so, not edited): both the rendering // and game logic (a full "step") is executed. if (!this._sceneStack.step(elapsedTime)) { return false; // Return if game asked to be stopped. } this._hasJustResumed = false; } this.getInputManager().onFrameEnded(); return true; } catch (e) { if (this._debuggerClient) this._debuggerClient.onUncaughtException(e as Error); throw e; } }); setTimeout(() => { this._setupSessionMetrics(); }, 4000); if (this._captureManager) { this._captureManager.setupCaptureOptions(this._isPreview); } } catch (e) { if (this._debuggerClient) this._debuggerClient.onUncaughtException(e as Error); 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 { if (this._inGameEditor) { this._inGameEditor.dispose(); } 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 loop is paused, for debugging/edition purposes. * @returns true if the current game is paused */ isPaused(): boolean { return this._paused; } /** * Check if the game should display in-game edition tools or not. * @returns true if the current game is being edited. */ isInGameEdition(): boolean { return this._isInGameEdition; } /** * Return in-game editor. */ getInGameEditor(): InGameEditor | null { return this._inGameEditor; } isBehaviorActivatedByDefaultInEditor(type: string): boolean { return this._data.activatedByDefaultInEditorBehaviors ? this._data.activatedByDefaultInEditorBehaviors.includes(type) : false; } /** * Set the maximum FPS of the game. * @param maximumFps The maximum FPS. */ setMaximumFps(maximumFps: integer) { this._maxFPS = maximumFps; } /** * 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: syncOptions.syncGameVariables === false ? undefined : this._variables.getNetworkSyncData(syncOptions), sm: syncOptions.syncSounds ? this.getSoundManager().getNetworkSyncData() : undefined, ss: this._sceneStack.getNetworkSyncData(syncOptions) || undefined, }; if (syncOptions.syncGameVariables !== false) { 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, options: UpdateFromNetworkSyncDataOptions ) { this._throwIfDisposed(); if (syncData.var) { this._variables.updateFromNetworkSyncData(syncData.var, options); } if (syncData.sm) { this.getSoundManager().updateFromNetworkSyncData(syncData.sm); } 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, options ); } } } } private _throwIfDisposed(): void { if (this._wasDisposed) { throw 'The RuntimeGame has been disposed and should not be used anymore.'; } } } }