diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index dffde9198de8..969be160b120 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -216,17 +216,30 @@ bool ExporterHelper::ExportProjectForPixiPreview( // Strip the project (*after* generating events as the events may use stripped // things (objects groups...)) gd::ProjectStripper::StripProjectForExport(exportedProject); - exportedProject.SetFirstLayout(options.layoutName); previousTime = LogTimeSpent("Data stripping", previousTime); // Create the setup options passed to the gdjs.RuntimeGame gd::SerializerElement runtimeGameOptions; runtimeGameOptions.AddChild("isPreview").SetBoolValue(true); + + auto &initialRuntimeGameStatus = + runtimeGameOptions.AddChild("initialRuntimeGameStatus"); + initialRuntimeGameStatus.AddChild("sceneName") + .SetStringValue(options.layoutName); + if (options.isInGameEdition) { + initialRuntimeGameStatus.AddChild("isInGameEdition").SetBoolValue(true); + } if (!options.externalLayoutName.empty()) { - runtimeGameOptions.AddChild("injectExternalLayout") + initialRuntimeGameStatus.AddChild("injectedExternalLayoutName") .SetValue(options.externalLayoutName); + + if (options.isInGameEdition) { + initialRuntimeGameStatus.AddChild("skipCreatingInstancesFromScene") + .SetBoolValue(true); + } } + runtimeGameOptions.AddChild("projectDataOnlyExport") .SetBoolValue(options.projectDataOnlyExport); runtimeGameOptions.AddChild("nativeMobileApp") @@ -240,9 +253,6 @@ bool ExporterHelper::ExportProjectForPixiPreview( if (options.isDevelopmentEnvironment) { runtimeGameOptions.AddChild("environment").SetStringValue("dev"); } - if (options.isInGameEdition) { - runtimeGameOptions.AddChild("isInGameEdition").SetBoolValue(true); - } if (!options.gdevelopResourceToken.empty()) { runtimeGameOptions.AddChild("gdevelopResourceToken") .SetStringValue(options.gdevelopResourceToken); diff --git a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts index 274f4359d3d7..79bd32af24ff 100644 --- a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts @@ -268,31 +268,81 @@ namespace gdjs { // TODO: if fatal error, should probably reload. The editor should handle this // as it knows the current scene to show. }); - } else if (data.command === 'requestSceneReplace') { + } else if (data.command === 'switchForInGameEdition') { if (!this._runtimegame.isInGameEdition()) return; const sceneName = data.sceneName || null; + const externalLayoutName = data.externalLayoutName || null; if (!sceneName) { - logger.warn('No scene name specified, requestSceneReplace aborted'); + logger.warn('No scene name specified, switchForInGameEdition aborted'); return; } - const currentScene = runtimeGame.getSceneStack().getCurrentScene(); - if (currentScene && currentScene.getName() === sceneName) { - return; + const runtimeGameOptions = this._runtimegame.getAdditionalOptions(); + if (runtimeGameOptions.initialRuntimeGameStatus) { + // Skip changing the scene if we're already on the state that is being requested. + if ( + runtimeGameOptions.initialRuntimeGameStatus.sceneName === + sceneName && + runtimeGameOptions.initialRuntimeGameStatus + .injectedExternalLayoutName === externalLayoutName + ) { + return; + } } - runtimeGame.getSceneStack().replace(sceneName, true); - // TODO: handle external layouts. - - // TODO: if fatal error, should probably reload. The editor should handle this - // as it knows the current scene to show. + runtimeGame + .getSceneStack() + .replace({ + sceneName, + externalLayoutName, + skipCreatingInstancesFromScene: !!externalLayoutName, + clear: true, + }); + + // Update initialRuntimeGameStatus so that a hard reload + // will come back to the same state, and so that we can check later + // if the game is already on the state that is being requested. + runtimeGameOptions.initialRuntimeGameStatus = { + isPaused: runtimeGame.isPaused(), + isInGameEdition: runtimeGame.isInGameEdition(), + sceneName: sceneName, + injectedExternalLayoutName: externalLayoutName, + skipCreatingInstancesFromScene: !!externalLayoutName, + }; } else if (data.command === 'updateInstances') { // TODO: do an update/partial hot reload of the instances } else if (data.command === 'hardReload') { // This usually means that the preview was modified so much that an entire reload // is needed, or that the runtime itself could have been modified. - location.reload(); + try { + const reloadUrl = new URL(location.href); + + // Construct the initial status to be restored. + const initialRuntimeGameStatus = this._runtimegame.getAdditionalOptions() + .initialRuntimeGameStatus; + const runtimeGameStatus: RuntimeGameStatus = { + isPaused: this._runtimegame.isPaused(), + isInGameEdition: this._runtimegame.isInGameEdition(), + sceneName: initialRuntimeGameStatus?.sceneName || null, + injectedExternalLayoutName: + initialRuntimeGameStatus?.injectedExternalLayoutName || null, + skipCreatingInstancesFromScene: + initialRuntimeGameStatus?.skipCreatingInstancesFromScene || false, + }; + + reloadUrl.searchParams.set( + 'runtimeGameStatus', + JSON.stringify(runtimeGameStatus) + ); + location.replace(reloadUrl); + } catch (error) { + logger.error( + 'Could not reload the game with the new initial status', + error + ); + location.reload(); + } } else { logger.info( 'Unknown command "' + data.command + '" received by the debugger.' @@ -471,7 +521,7 @@ namespace gdjs { payload: { isPaused: this._runtimegame.isPaused(), isInGameEdition: this._runtimegame.isInGameEdition(), - currentSceneName: currentScene ? currentScene.getName() : null, + sceneName: currentScene ? currentScene.getName() : null, }, }) ); diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index 761a19e89fcd..4ff1cd5c2fe5 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -41,6 +41,43 @@ namespace gdjs { 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. + */ + type RuntimeGameStatus = { + isPaused: boolean; + isInGameEdition: boolean; + sceneName: string | null; + injectedExternalLayoutName: string | null; + skipCreatingInstancesFromScene: boolean; + }; + + /** + * 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, + }; + } catch (e) { + return null; + } + }; + /** Options given to the game at startup. */ export type RuntimeGameOptions = { /** if true, force fullscreen. */ @@ -48,11 +85,10 @@ namespace gdjs { /** if true, game is run as a preview launched from an editor. */ isPreview?: boolean; - /** if true, game is run for being edited from the editor. */ - isInGameEdition?: boolean; - /** The name of the external layout to create in the scene at position 0;0. */ - injectExternalLayout?: string; + /** if set, the status of the game to be restored. */ + initialRuntimeGameStatus?: RuntimeGameStatus; + /** Script files, used for hot-reloading. */ scriptFiles?: Array; /** if true, export is a partial preview without events. */ @@ -173,10 +209,6 @@ namespace gdjs { //Inputs : _inputManager: InputManager; - /** - * Allow to specify an external layout to insert in the first scene. - */ - _injectExternalLayout: any; _options: RuntimeGameOptions; /** @@ -207,6 +239,19 @@ namespace gdjs { */ 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, @@ -255,7 +300,6 @@ namespace gdjs { ); 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; @@ -265,8 +309,6 @@ namespace gdjs { this._options.captureOptions || {} ) : null; - this._isPreview = this._options.isPreview || false; - this._isInGameEdition = this._options.isInGameEdition || false; this._sessionId = null; this._playerId = null; @@ -884,7 +926,9 @@ namespace gdjs { } private _getFirstSceneName(): string { - const firstSceneName = this._data.firstLayout; + const firstSceneName = + this._options.initialRuntimeGameStatus?.sceneName || + this._data.firstLayout; return this.hasScene(firstSceneName) ? firstSceneName : // There is always at least a scene @@ -904,10 +948,15 @@ namespace gdjs { this._forceGameResolutionUpdate(); // Load the first scene - this._sceneStack.push( - this._getFirstSceneName(), - this._injectExternalLayout - ); + this._sceneStack.push({ + sceneName: this._getFirstSceneName(), + externalLayoutName: + this._options.initialRuntimeGameStatus + ?.injectedExternalLayoutName || undefined, + skipCreatingInstancesFromScene: + this._options.initialRuntimeGameStatus + ?.skipCreatingInstancesFromScene || false, + }); this._watermark.displayAtStartup(); //Uncomment to profile the first x frames of the game. @@ -1302,14 +1351,6 @@ namespace gdjs { return this._isPreview; } - /** - * 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; - } - /** * Check if the game loop is paused, for debugging/edition purposes. * @returns true if the current game is paused @@ -1318,6 +1359,14 @@ namespace gdjs { 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; + } + /** * Check if the game should call GDevelop development APIs or not. * diff --git a/GDJS/Runtime/runtimescene.ts b/GDJS/Runtime/runtimescene.ts index 3f9c730e312b..48415eff5006 100644 --- a/GDJS/Runtime/runtimescene.ts +++ b/GDJS/Runtime/runtimescene.ts @@ -121,10 +121,15 @@ namespace gdjs { /** * Load the runtime scene from the given scene. - * @param sceneData An object containing the scene data. + * + * @param sceneAndExtensionsData The data of the scene and extension variables to be loaded. + * @param options Options to change what is loaded. * @see gdjs.RuntimeGame#getSceneAndExtensionsData */ - loadFromScene(sceneAndExtensionsData: SceneAndExtensionsData | null) { + loadFromScene( + sceneAndExtensionsData: SceneAndExtensionsData | null, + options?: { skipCreatingInstances?: boolean } + ) { if (!sceneAndExtensionsData) { logger.error('loadFromScene was called without a scene'); return; @@ -184,14 +189,16 @@ namespace gdjs { } //Create initial instances of objects - this.createObjectsFrom( - sceneData.instances, - 0, - 0, - 0, - /*trackByPersistentUuid=*/ - true - ); + if (!options || !options.skipCreatingInstances) { + this.createObjectsFrom( + sceneData.instances, + 0, + 0, + 0, + /*trackByPersistentUuid=*/ + true + ); + } // Set up the default z order (for objects created from events) this._setLayerDefaultZOrders(); diff --git a/GDJS/Runtime/scenestack.ts b/GDJS/Runtime/scenestack.ts index f0e7d42dab11..0548e6f58d62 100644 --- a/GDJS/Runtime/scenestack.ts +++ b/GDJS/Runtime/scenestack.ts @@ -2,6 +2,16 @@ namespace gdjs { const logger = new gdjs.Logger('Scene stack'); const debugLogger = new gdjs.Logger('Multiplayer - Debug'); + interface PushSceneOptions { + sceneName: string; + externalLayoutName?: string; + skipCreatingInstancesFromScene?: boolean; + }; + + interface ReplaceSceneOptions extends PushSceneOptions { + clear: boolean; + }; + /** * Hold the stack of scenes ({@link gdjs.RuntimeScene}) being played. */ @@ -113,15 +123,28 @@ namespace gdjs { } } + /** - * Pause the scene currently being played and start the new scene that is specified. - * If `externalLayoutName` is set, also instantiate the objects from this external layout. + * Pause the scene currently being played and start the new scene that is specified in `options.sceneName`. + * If `options.externalLayoutName` is set, also instantiate the objects from this external layout. + * + * @param options Contains the scene name and optional external layout name to instantiate. + * @param deprecatedExternalLayoutName Deprecated, use `options.externalLayoutName` instead. */ push( - newSceneName: string, - externalLayoutName?: string + options: PushSceneOptions | string, + deprecatedExternalLayoutName?: string ): gdjs.RuntimeScene | null { this._throwIfDisposed(); + console.log({options, deprecatedExternalLayoutName}) + + const sceneName = + typeof options === 'string' ? options : options.sceneName; + const skipCreatingInstancesFromScene = + typeof options === 'string' ? false : options.skipCreatingInstancesFromScene; + const externalLayoutName = + deprecatedExternalLayoutName || + (typeof options === 'string' ? undefined : options.externalLayoutName); // Tell the scene it's being paused const currentScene = this._stack[this._stack.length - 1]; @@ -131,35 +154,43 @@ namespace gdjs { // Avoid a risk of displaying an intermediate loading screen // during 1 frame. - if (this._runtimeGame.areSceneAssetsReady(newSceneName)) { - return this._loadNewScene(newSceneName, externalLayoutName); + if (this._runtimeGame.areSceneAssetsReady(sceneName)) { + return this._loadNewScene({ + sceneName, + externalLayoutName, + skipCreatingInstancesFromScene, + }); } this._isNextLayoutLoading = true; - this._runtimeGame.loadSceneAssets(newSceneName).then(() => { - this._loadNewScene(newSceneName); + this._runtimeGame.loadSceneAssets(sceneName).then(() => { + this._loadNewScene({ + sceneName, + externalLayoutName, + skipCreatingInstancesFromScene, + }); this._isNextLayoutLoading = false; }); return null; } - private _loadNewScene( - newSceneName: string, - externalLayoutName?: string - ): gdjs.RuntimeScene { + private _loadNewScene(options: PushSceneOptions): gdjs.RuntimeScene { this._throwIfDisposed(); // Load the new one const newScene = new gdjs.RuntimeScene(this._runtimeGame); newScene.loadFromScene( - this._runtimeGame.getSceneAndExtensionsData(newSceneName) + this._runtimeGame.getSceneAndExtensionsData(options.sceneName), + { + skipCreatingInstances: options.skipCreatingInstancesFromScene, + } ); this._wasFirstSceneLoaded = true; // Optionally create the objects from an external layout. - if (externalLayoutName) { + if (options.externalLayoutName) { const externalLayoutData = this._runtimeGame.getExternalLayoutData( - externalLayoutName + options.externalLayoutName ); if (externalLayoutData) { newScene.createObjectsFrom( @@ -177,10 +208,15 @@ namespace gdjs { } /** - * Start the specified scene, replacing the one currently being played. - * If `clear` is set to true, all running scenes are also removed from the stack of scenes. + * Start the scene in `options.sceneName`, replacing the one currently being played. + * If `options.clear` is set to true, all running scenes are also removed from the stack of scenes. + * + * @param options Contains the scene name and optional external layout name to instantiate. + * @param deprecatedClear Deprecated, use `options.clear` instead. */ - replace(newSceneName: string, clear?: boolean): gdjs.RuntimeScene | null { + replace(options: ReplaceSceneOptions | string, deprecatedClear?: boolean): gdjs.RuntimeScene | null { + const clear = deprecatedClear || typeof options === 'string' ? false : options.clear; + this._throwIfDisposed(); if (!!clear) { // Unload all the scenes @@ -199,7 +235,7 @@ namespace gdjs { } } } - return this.push(newSceneName); + return this.push(options); } /** diff --git a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js index e6fd7ee60f36..9edec2e8a403 100644 --- a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js +++ b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js @@ -63,15 +63,23 @@ export const EmbeddedGameFrame = ({ const { sceneName, externalLayoutName } = options; if (!previewIndexHtmlLocation) { - console.info('Launching preview for embedded game.'); + console.info( + externalLayoutName + ? `Launching in-game edition preview for external layout "${externalLayoutName}" (scene: "${sceneName}").` + : `Launching in-game edition preview for scene "${sceneName}".` + ); onLaunchPreviewForInGameEdition({ sceneName, externalLayoutName }); } else { - // TODO: handle external layouts (and custom objects later). - console.info(`Switching previews to scene "${sceneName}".`); + console.info( + externalLayoutName + ? `Switching in-game edition previews to external layout "${externalLayoutName}" (scene: "${sceneName}").` + : `Switching in-game edition previews to scene "${sceneName}".` + ); previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { previewDebuggerServer.sendMessage(debuggerId, { - command: 'requestSceneReplace', + command: 'switchForInGameEdition', sceneName, + externalLayoutName, }); }); } diff --git a/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js b/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js index f34146d2f616..c8ebcfa37edc 100644 --- a/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js +++ b/newIDE/app/src/ExportAndShare/PreviewLauncher.flow.js @@ -68,7 +68,7 @@ export type DebuggerId = number; export type DebuggerStatus = {| isPaused: boolean, isInGameEdition: boolean, - currentSceneName: string | null, + sceneName: string | null, |}; /** The callbacks for a debugger server used for previews. */ diff --git a/newIDE/app/src/MainFrame/PreviewState.js b/newIDE/app/src/MainFrame/PreviewState.js index 17da2fa8398d..fcab9fda4a0a 100644 --- a/newIDE/app/src/MainFrame/PreviewState.js +++ b/newIDE/app/src/MainFrame/PreviewState.js @@ -23,7 +23,6 @@ export type PreviewState = {| |}; type PreviewDebuggerServerWatcherResults = {| - getInGameEditionPreviewStatus: () => DebuggerStatus | null, hasNonEditionPreviewsRunning: boolean, hotReloadLogs: Array, @@ -91,7 +90,7 @@ export const usePreviewDebuggerServerWatcher = ( [id]: { isPaused: !!parsedMessage.payload.isPaused, isInGameEdition: !!parsedMessage.payload.isInGameEdition, - currentSceneName: parsedMessage.payload.currentSceneName, + sceneName: parsedMessage.payload.sceneName, }, })); } @@ -125,24 +124,7 @@ export const usePreviewDebuggerServerWatcher = ( key => !debuggerStatus[+key].isInGameEdition ); - const getInGameEditionPreviewStatus = React.useCallback( - () => { - const inGameEditionPreviewKey = Object.keys(debuggerStatus).find(key => { - if (debuggerStatus[+key].isInGameEdition) { - return true; - } - - return false; - }); - - if (!inGameEditionPreviewKey) return null; - return debuggerStatus[+inGameEditionPreviewKey]; - }, - [debuggerStatus] - ); - return { - getInGameEditionPreviewStatus, hasNonEditionPreviewsRunning, hotReloadLogs, clearHotReloadLogs, diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 4fe29b713a38..b21028e97609 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -412,7 +412,6 @@ const MainFrame = (props: Props) => { _previewLauncher.current && _previewLauncher.current.getPreviewDebuggerServer(); const { - getInGameEditionPreviewStatus, hasNonEditionPreviewsRunning, hotReloadLogs, clearHotReloadLogs, @@ -1813,44 +1812,20 @@ const MainFrame = (props: Props) => { const relaunchAndThenHardReloadAllPreviews = React.useCallback( async () => { - const runningInGameEditionPreviewStatus = getInGameEditionPreviewStatus(); - - // Note: this is an "approximation", as all previews will be relaunched with - // the same configuration, which could not be the case if there was a mix of - // in-game edition and non-in-game edition previews. - // To fix this, preview configuration (scene name, etc...) should be persisted - // for each preview, for example in the URL, so it survives to a reload. - if (runningInGameEditionPreviewStatus) { - console.info('Relaunching preview for in-game edition...'); - await launchPreview({ - networkPreview: false, - hotReload: false, - forceDiagnosticReport: false, - isForInGameEdition: { - forcedSceneName: - runningInGameEditionPreviewStatus.currentSceneName || '', - // TODO: add support for forced external layout name. - forcedExternalLayoutName: null, - }, - numberOfWindows: 0, - }); - } else if (hasNonEditionPreviewsRunning) { - console.info('Relaunching preview...'); - await launchPreview({ - networkPreview: false, - hotReload: false, - forceDiagnosticReport: false, - numberOfWindows: 0, - }); - } + // Build a new preview (so that any changes in runtime files are picked up) + // and then ask all previews to "hard reload" themselves (i.e: refresh their page). + await launchPreview({ + networkPreview: false, + hotReload: false, + forceDiagnosticReport: false, + numberOfWindows: 0, + }); hardReloadAllPreviews(); }, [ hardReloadAllPreviews, launchPreview, - getInGameEditionPreviewStatus, - hasNonEditionPreviewsRunning, ] );