diff --git a/Core/GDCore/Project/PropertyDescriptor.cpp b/Core/GDCore/Project/PropertyDescriptor.cpp index 4b5b7254d8f7..bc35302f056f 100644 --- a/Core/GDCore/Project/PropertyDescriptor.cpp +++ b/Core/GDCore/Project/PropertyDescriptor.cpp @@ -51,7 +51,9 @@ void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) { currentValue = element.GetChild("value").GetStringValue(); type = element.GetChild("type").GetStringValue(); if (type == "Number") { - gd::String unitName = element.GetChild("unit").GetStringValue(); + gd::String unitName = element.HasChild("unit") + ? element.GetChild("unit").GetStringValue() + : ""; measurementUnit = gd::MeasurementUnit::HasDefaultMeasurementUnitNamed(unitName) ? measurementUnit = diff --git a/Extensions/TextObject/textruntimeobject.ts b/Extensions/TextObject/textruntimeobject.ts index e44561aa307d..d878fde1a215 100644 --- a/Extensions/TextObject/textruntimeobject.ts +++ b/Extensions/TextObject/textruntimeobject.ts @@ -320,7 +320,7 @@ namespace gdjs { return this._renderer.getRendererObject(); } - update(instanceContainer: gdjs.RuntimeInstanceContainer): void { + updatePreRender(instanceContainer: gdjs.RuntimeInstanceContainer): void { this._renderer.ensureUpToDate(); } diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index ac3a8cd30bb2..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") diff --git a/GDJS/GDJS/IDE/ExporterHelper.h b/GDJS/GDJS/IDE/ExporterHelper.h index ad5f5f6158ef..82baf848e80b 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.h +++ b/GDJS/GDJS/IDE/ExporterHelper.h @@ -45,6 +45,7 @@ struct PreviewExportOptions { projectDataOnlyExport(false), fullLoadingScreen(false), isDevelopmentEnvironment(false), + isInGameEdition(false), nonRuntimeScriptsCacheBurst(0), fallbackAuthorId(""), fallbackAuthorUsername(""), @@ -169,6 +170,14 @@ struct PreviewExportOptions { return *this; } + /** + * \brief Set if the export is made for being edited in the editor. + */ + PreviewExportOptions &SetIsInGameEdition(bool enable) { + isInGameEdition = enable; + return *this; + } + /** * \brief If set to a non zero value, the exported script URLs will have an * extra search parameter added (with the given value) to ensure browser cache @@ -291,6 +300,7 @@ struct PreviewExportOptions { bool projectDataOnlyExport; bool fullLoadingScreen; bool isDevelopmentEnvironment; + bool isInGameEdition; unsigned int nonRuntimeScriptsCacheBurst; gd::String electronRemoteRequirePath; gd::String gdevelopResourceToken; diff --git a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts index 51622df0d707..79bd32af24ff 100644 --- a/GDJS/Runtime/debugger-client/abstract-debugger-client.ts +++ b/GDJS/Runtime/debugger-client/abstract-debugger-client.ts @@ -245,6 +245,8 @@ namespace gdjs { that.sendRuntimeGameDump(); } else if (data.command === 'refresh') { that.sendRuntimeGameDump(); + } else if (data.command === 'getStatus') { + that.sendRuntimeGameStatus(); } else if (data.command === 'set') { that.set(data.path, data.newValue); } else if (data.command === 'call') { @@ -263,7 +265,84 @@ namespace gdjs { } else if (data.command === 'hotReload') { that._hotReloader.hotReload().then((logs) => { that.sendHotReloaderLogs(logs); + // TODO: if fatal error, should probably reload. The editor should handle this + // as it knows the current scene to show. }); + } 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, switchForInGameEdition aborted'); + 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, + 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. + 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.' @@ -434,6 +513,20 @@ namespace gdjs { return true; } + sendRuntimeGameStatus(): void { + const currentScene = this._runtimegame.getSceneStack().getCurrentScene(); + this._sendMessage( + circularSafeStringify({ + command: 'status', + payload: { + isPaused: this._runtimegame.isPaused(), + isInGameEdition: this._runtimegame.isInGameEdition(), + sceneName: currentScene ? currentScene.getName() : null, + }, + }) + ); + } + /** * Dump all the relevant data from the {@link RuntimeGame} instance and send it to the server. */ @@ -543,26 +636,11 @@ namespace gdjs { ); } - /** - * Callback called when the game is paused. - */ - sendGamePaused(): void { + sendInstancesUpdated(runtimeObjects: gdjs.RuntimeObject[]): void { this._sendMessage( circularSafeStringify({ - command: 'game.paused', - payload: null, - }) - ); - } - - /** - * Callback called when the game is resumed. - */ - sendGameResumed(): void { - this._sendMessage( - circularSafeStringify({ - command: 'game.resumed', - payload: null, + command: 'instances.updated', + payload: 'TODO', }) ); } diff --git a/GDJS/Runtime/debugger-client/hot-reloader.ts b/GDJS/Runtime/debugger-client/hot-reloader.ts index 141e4e9600d2..9c2c266d80ed 100644 --- a/GDJS/Runtime/debugger-client/hot-reloader.ts +++ b/GDJS/Runtime/debugger-client/hot-reloader.ts @@ -144,8 +144,9 @@ namespace gdjs { }); } - hotReload(): Promise { + async hotReload(): Promise { logger.info('Hot reload started'); + const wasPaused = this._runtimeGame.isPaused(); this._runtimeGame.pause(true); this._logs = []; @@ -168,62 +169,59 @@ namespace gdjs { } // Reload projectData and runtimeGameOptions stored by convention in data.js: - return this._reloadScript('data.js').then(() => { - const newProjectData: ProjectData = gdjs.projectData; + await this._reloadScript('data.js'); - const newRuntimeGameOptions: RuntimeGameOptions = - gdjs.runtimeGameOptions; + const newProjectData: ProjectData = gdjs.projectData; - const newScriptFiles = newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[]; - const projectDataOnlyExport = !!newRuntimeGameOptions.projectDataOnlyExport; + const newRuntimeGameOptions: RuntimeGameOptions = gdjs.runtimeGameOptions; - // Reload the changed scripts, which will have the side effects of re-running - // the new scripts, potentially replacing the code of the free functions from - // extensions (which is fine) and registering updated behaviors (which will - // need to be re-instantiated in runtime objects). - return this.reloadScriptFiles( + const newScriptFiles = newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[]; + const projectDataOnlyExport = !!newRuntimeGameOptions.projectDataOnlyExport; + + // Reload the changed scripts, which will have the side effects of re-running + // the new scripts, potentially replacing the code of the free functions from + // extensions (which is fine) and registering updated behaviors (which will + // need to be re-instantiated in runtime objects). + try { + await this.reloadScriptFiles( newProjectData, oldScriptFiles, newScriptFiles, projectDataOnlyExport - ) - .then(() => { - const changedRuntimeBehaviors = this._computeChangedRuntimeBehaviors( - oldBehaviorConstructors, - gdjs.behaviorsTypes.items - ); - return this._hotReloadRuntimeGame( - oldProjectData, - newProjectData, - changedRuntimeBehaviors, - this._runtimeGame - ); - }) - .catch((error) => { - const errorTarget = error.target; - if (errorTarget instanceof HTMLScriptElement) { - this._logs.push({ - kind: 'fatal', - message: 'Unable to reload script: ' + errorTarget.src, - }); - } else { - this._logs.push({ - kind: 'fatal', - message: - 'Unexpected error happened while hot-reloading: ' + - error.message, - }); - } - }) - .then(() => { - logger.info( - 'Hot reload finished with logs:', - this._logs.map((log) => '\n' + log.kind + ': ' + log.message) - ); - this._runtimeGame.pause(false); - return this._logs; + ); + + const changedRuntimeBehaviors = this._computeChangedRuntimeBehaviors( + oldBehaviorConstructors, + gdjs.behaviorsTypes.items + ); + await this._hotReloadRuntimeGame( + oldProjectData, + newProjectData, + changedRuntimeBehaviors, + this._runtimeGame + ); + } catch (error) { + const errorTarget = error.target; + if (errorTarget instanceof HTMLScriptElement) { + this._logs.push({ + kind: 'fatal', + message: 'Unable to reload script: ' + errorTarget.src, }); - }); + } else { + this._logs.push({ + kind: 'fatal', + message: + 'Unexpected error happened while hot-reloading: ' + error.message, + }); + } + } + + logger.info( + 'Hot reload finished with logs:', + this._logs.map((log) => '\n' + log.kind + ': ' + log.message) + ); + this._runtimeGame.pause(wasPaused); + return this._logs; } _computeChangedRuntimeBehaviors( diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index 14bd400bab81..4ff1cd5c2fe5 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -41,14 +41,54 @@ 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. */ 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; + + /** 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. */ @@ -169,10 +209,6 @@ namespace gdjs { //Inputs : _inputManager: InputManager; - /** - * Allow to specify an external layout to insert in the first scene. - */ - _injectExternalLayout: any; _options: RuntimeGameOptions; /** @@ -187,6 +223,7 @@ namespace gdjs { _sessionMetricsInitialized: boolean = false; _disableMetrics: boolean = false; _isPreview: boolean; + _isInGameEdition: boolean; /** * The capture manager, used to manage captures (screenshots, videos, etc...). @@ -202,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, @@ -250,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; @@ -260,7 +309,6 @@ namespace gdjs { this._options.captureOptions || {} ) : null; - this._isPreview = this._options.isPreview || false; this._sessionId = null; this._playerId = null; @@ -710,8 +758,7 @@ namespace gdjs { this._paused = enable; if (this._debuggerClient) { - if (this._paused) this._debuggerClient.sendGamePaused(); - else this._debuggerClient.sendGameResumed(); + this._debuggerClient.sendRuntimeGameStatus(); } } @@ -872,11 +919,16 @@ namespace gdjs { await loadAssets(onProgress); await loadingScreen.unload(); - this.pause(false); + + if (!this._isInGameEdition) { + this.pause(false); + } } 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 @@ -896,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. @@ -917,11 +974,25 @@ namespace gdjs { this._setupGameVisibilityEvents(); // The standard game loop + let lastFrameSceneName: string | null = null; let accumulatedElapsedTime = 0; this._hasJustResumed = false; this._renderer.startGameLoop((lastCallElapsedTime) => { try { - if (this._paused) { + if (this._debuggerClient) { + // Watch the scene name to automatically update debugger when a scene is changed. + const currentScene = this.getSceneStack().getCurrentScene(); + if ( + currentScene && + currentScene.getName() !== lastFrameSceneName + ) { + lastFrameSceneName = currentScene.getName(); + this._debuggerClient.sendRuntimeGameStatus(); + } + } + + if (this._paused && !this._isInGameEdition) { + // The game is paused, but not being edited, so we entirely skip any logic. return true; } @@ -946,13 +1017,20 @@ namespace gdjs { this._notifyScenesForGameResolutionResize = false; } - // Render and step the scene. - if (this._sceneStack.step(elapsedTime)) { - this.getInputManager().onFrameEnded(); - this._hasJustResumed = false; + if (this._paused && this._isInGameEdition) { + // The game is paused for edition: the game loop continues to run, + // but the game logic is not executed. + this._sceneStack.renderWithoutStep(); return true; + } else { + // Render and step the scene. + if (this._sceneStack.step(elapsedTime)) { + this.getInputManager().onFrameEnded(); + this._hasJustResumed = false; + return true; + } + return false; } - return false; } catch (e) { if (this._debuggerClient) this._debuggerClient.onUncaughtException(e); @@ -1273,6 +1351,22 @@ namespace gdjs { 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; + } + /** * Check if the game should call GDevelop development APIs or not. * diff --git a/GDJS/Runtime/runtimescene.ts b/GDJS/Runtime/runtimescene.ts index 11d8b49c13b2..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(); @@ -358,7 +365,7 @@ namespace gdjs { } /** - * Step and render the scene. + * Step (execute the game logic) and render the scene. * @param elapsedTime In milliseconds * @return true if the game loop should continue, false if a scene change/push/pop * or a game stop was requested. @@ -418,6 +425,23 @@ namespace gdjs { if (this._profiler) { this._profiler.end('callbacks and extensions (post-events)'); } + + this.render(); + + this._isJustResumed = false; + if (this._profiler) { + this._profiler.end('render'); + } + if (this._profiler) { + this._profiler.endFrame(); + } + return !!this.getRequestedChange(); + } + + /** + * Render the scene (but do not execute the game logic). + */ + render() { if (this._profiler) { this._profiler.begin('objects (pre-render, effects update)'); } @@ -447,21 +471,6 @@ namespace gdjs { ); } - this._isJustResumed = false; - this.render(); - if (this._profiler) { - this._profiler.end('render'); - } - if (this._profiler) { - this._profiler.endFrame(); - } - return !!this.getRequestedChange(); - } - - /** - * Render the PIXI container associated to the runtimeScene. - */ - render() { this._renderer.render(); } 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/GDevelop.js/Bindings/Bindings.idl b/GDevelop.js/Bindings/Bindings.idl index 44c38b12cfe7..7f2566762bda 100644 --- a/GDevelop.js/Bindings/Bindings.idl +++ b/GDevelop.js/Bindings/Bindings.idl @@ -3843,6 +3843,7 @@ interface PreviewExportOptions { [Ref] PreviewExportOptions SetNativeMobileApp(boolean enable); [Ref] PreviewExportOptions SetFullLoadingScreen(boolean enable); [Ref] PreviewExportOptions SetIsDevelopmentEnvironment(boolean enable); + [Ref] PreviewExportOptions SetIsInGameEdition(boolean enable); [Ref] PreviewExportOptions SetNonRuntimeScriptsCacheBurst(unsigned long value); [Ref] PreviewExportOptions SetElectronRemoteRequirePath([Const] DOMString electronRemoteRequirePath); [Ref] PreviewExportOptions SetGDevelopResourceToken([Const] DOMString gdevelopResourceToken); diff --git a/GDevelop.js/types.d.ts b/GDevelop.js/types.d.ts index 608df3834dff..64cdfdbe330e 100644 --- a/GDevelop.js/types.d.ts +++ b/GDevelop.js/types.d.ts @@ -2850,6 +2850,7 @@ export class PreviewExportOptions extends EmscriptenObject { setNativeMobileApp(enable: boolean): PreviewExportOptions; setFullLoadingScreen(enable: boolean): PreviewExportOptions; setIsDevelopmentEnvironment(enable: boolean): PreviewExportOptions; + setIsInGameEdition(enable: boolean): PreviewExportOptions; setNonRuntimeScriptsCacheBurst(value: number): PreviewExportOptions; setElectronRemoteRequirePath(electronRemoteRequirePath: string): PreviewExportOptions; setGDevelopResourceToken(gdevelopResourceToken: string): PreviewExportOptions; diff --git a/GDevelop.js/types/gdpreviewexportoptions.js b/GDevelop.js/types/gdpreviewexportoptions.js index f5e25888d444..18e7021a730d 100644 --- a/GDevelop.js/types/gdpreviewexportoptions.js +++ b/GDevelop.js/types/gdpreviewexportoptions.js @@ -13,6 +13,7 @@ declare class gdPreviewExportOptions { setNativeMobileApp(enable: boolean): gdPreviewExportOptions; setFullLoadingScreen(enable: boolean): gdPreviewExportOptions; setIsDevelopmentEnvironment(enable: boolean): gdPreviewExportOptions; + setIsInGameEdition(enable: boolean): gdPreviewExportOptions; setNonRuntimeScriptsCacheBurst(value: number): gdPreviewExportOptions; setElectronRemoteRequirePath(electronRemoteRequirePath: string): gdPreviewExportOptions; setGDevelopResourceToken(gdevelopResourceToken: string): gdPreviewExportOptions; diff --git a/newIDE/app/src/CommandPalette/CommandManager.js b/newIDE/app/src/CommandPalette/CommandManager.js index ea5310c1f140..a5cdcb806c03 100644 --- a/newIDE/app/src/CommandPalette/CommandManager.js +++ b/newIDE/app/src/CommandPalette/CommandManager.js @@ -51,18 +51,19 @@ export default class CommandManager implements CommandManagerInterface { } registerCommand = (commandName: CommandName, command: Command) => { - if (this._commands[commandName]) - return console.warn( - `Tried to register command ${commandName}, but it is already registered.` - ); + if (this._commands[commandName]) return; + // if (this._commands[commandName]) + // return console.warn( + // `Tried to register command ${commandName}, but it is already registered.` + // ); this._commands[commandName] = command; }; deregisterCommand = (commandName: CommandName) => { - if (!this._commands[commandName]) - return console.warn( - `Tried to deregister command ${commandName}, but it is not registered.` - ); + // if (!this._commands[commandName]) + // return console.warn( + // `Tried to deregister command ${commandName}, but it is not registered.` + // ); delete this._commands[commandName]; }; diff --git a/newIDE/app/src/Debugger/DebuggerSelector.js b/newIDE/app/src/Debugger/DebuggerSelector.js index f139989e92c2..4a234e282d89 100644 --- a/newIDE/app/src/Debugger/DebuggerSelector.js +++ b/newIDE/app/src/Debugger/DebuggerSelector.js @@ -1,38 +1,62 @@ // @flow import { t } from '@lingui/macro'; import * as React from 'react'; +import { I18n } from '@lingui/react'; import SelectField from '../UI/SelectField'; import SelectOption from '../UI/SelectOption'; -import { type DebuggerId } from '../ExportAndShare/PreviewLauncher.flow'; +import { + type DebuggerId, + type DebuggerStatus, +} from '../ExportAndShare/PreviewLauncher.flow'; type Props = {| selectedId: DebuggerId, - debuggerIds: Array, + debuggerStatus: { [DebuggerId]: DebuggerStatus }, onChooseDebugger: DebuggerId => void, |}; export default class DebuggerSelector extends React.Component { render() { - const hasDebuggers = !!this.props.debuggerIds.length; + const debuggerIds = Object.keys(this.props.debuggerStatus); + const hasDebuggers = !!debuggerIds.length; return ( - - this.props.onChooseDebugger(parseInt(value, 10) || 0) - } - disabled={!hasDebuggers} - > - {this.props.debuggerIds.map(id => ( - - ))} - {!hasDebuggers && ( - + + {({ i18n }) => ( + + this.props.onChooseDebugger(parseInt(value, 10) || 0) + } + disabled={!hasDebuggers} + > + {debuggerIds.map(id => { + const status = this.props.debuggerStatus[+id]; + const statusText = status.isPaused + ? status.isInGameEdition + ? t`Editing` + : t`Paused` + : status.isInGameEdition + ? t`Playing (in-game edition)` + : t`Playing`; + + return ( + + ); + })} + {!hasDebuggers && ( + + )} + )} - + ); } } diff --git a/newIDE/app/src/Debugger/index.js b/newIDE/app/src/Debugger/index.js index 9d2c66405c11..819ba4194ab5 100644 --- a/newIDE/app/src/Debugger/index.js +++ b/newIDE/app/src/Debugger/index.js @@ -14,6 +14,7 @@ import EmptyMessage from '../UI/EmptyMessage'; import { type PreviewDebuggerServer, type DebuggerId, + type DebuggerStatus, } from '../ExportAndShare/PreviewLauncher.flow'; import { type Log, LogsManager } from './DebuggerConsole'; @@ -53,7 +54,7 @@ type State = {| debuggerGameData: { [DebuggerId]: any }, profilerOutputs: { [DebuggerId]: ProfilerOutput }, profilingInProgress: { [DebuggerId]: boolean }, - gameIsPaused: { [DebuggerId]: boolean }, + debuggerStatus: { [DebuggerId]: DebuggerStatus }, selectedId: DebuggerId, logs: { [DebuggerId]: Array }, |}; @@ -70,7 +71,7 @@ export default class Debugger extends React.Component { debuggerGameData: {}, profilerOutputs: {}, profilingInProgress: {}, - gameIsPaused: {}, + debuggerStatus: {}, selectedId: 0, logs: {}, }; @@ -79,18 +80,22 @@ export default class Debugger extends React.Component { _debuggerLogs: Map = new Map(); updateToolbar = () => { - const { selectedId, gameIsPaused } = this.state; + const { selectedId, debuggerStatus } = this.state; const selectedDebuggerContents = this._debuggerContents[ this.state.selectedId ]; + const isSelectedDebuggerPaused = debuggerStatus[selectedId] + ? debuggerStatus[selectedId].isPaused + : false; + this.props.setToolbar( this._play(this.state.selectedId)} onPause={() => this._pause(this.state.selectedId)} - canPlay={this._hasSelectedDebugger() && gameIsPaused[selectedId]} - canPause={this._hasSelectedDebugger() && !gameIsPaused[selectedId]} + canPlay={this._hasSelectedDebugger() && isSelectedDebuggerPaused} + canPause={this._hasSelectedDebugger() && !isSelectedDebuggerPaused} canOpenProfiler={this._hasSelectedDebugger()} isProfilerShown={ !!selectedDebuggerContents && @@ -161,14 +166,14 @@ export default class Debugger extends React.Component { debuggerGameData, profilerOutputs, profilingInProgress, - gameIsPaused, + debuggerStatus, }) => { // Remove any data bound to the instance that might have been stored. // Otherwise this would be a memory leak. if (debuggerGameData[id]) delete debuggerGameData[id]; if (profilerOutputs[id]) delete profilerOutputs[id]; if (profilingInProgress[id]) delete profilingInProgress[id]; - if (gameIsPaused[id]) delete gameIsPaused[id]; + if (debuggerStatus[id]) delete debuggerStatus[id]; return { debuggerIds, @@ -181,7 +186,7 @@ export default class Debugger extends React.Component { debuggerGameData, profilerOutputs, profilingInProgress, - gameIsPaused, + debuggerStatus, }; }, () => this.updateToolbar() @@ -219,6 +224,11 @@ export default class Debugger extends React.Component { this.setState({ unregisterDebuggerServerCallbacks: unregisterCallbacks, }); + + // Fetch the status of each debugger client. + previewDebuggerServer.getExistingDebuggerIds().forEach(debuggerId => { + previewDebuggerServer.sendMessage(debuggerId, { command: 'getStatus' }); + }); }; _handleMessage = (id: DebuggerId, data: any) => { @@ -229,6 +239,16 @@ export default class Debugger extends React.Component { [id]: data.payload, }, }); + } else if (data.command === 'status') { + this.setState( + state => ({ + debuggerStatus: { + ...state.debuggerStatus, + [id]: data.payload, + }, + }), + () => this.updateToolbar() + ); } else if (data.command === 'profiler.output') { this.setState({ profilerOutputs: { @@ -244,20 +264,6 @@ export default class Debugger extends React.Component { this.setState(state => ({ profilingInProgress: { ...state.profilingInProgress, [id]: false }, })); - } else if (data.command === 'game.resumed') { - this.setState( - state => ({ - gameIsPaused: { ...state.gameIsPaused, [id]: false }, - }), - () => this.updateToolbar() - ); - } else if (data.command === 'game.paused') { - this.setState( - state => ({ - gameIsPaused: { ...state.gameIsPaused, [id]: true }, - }), - () => this.updateToolbar() - ); } else if (data.command === 'hotReloader.logs') { // Nothing to do. } else if (data.command === 'console.log') { @@ -276,24 +282,14 @@ export default class Debugger extends React.Component { const { previewDebuggerServer } = this.props; previewDebuggerServer.sendMessage(id, { command: 'play' }); - this.setState( - state => ({ - gameIsPaused: { ...state.gameIsPaused, [id]: false }, - }), - () => this.updateToolbar() - ); + // Pause status is transmitted by the game (using `status`). }; _pause = (id: DebuggerId) => { const { previewDebuggerServer } = this.props; previewDebuggerServer.sendMessage(id, { command: 'pause' }); - this.setState( - state => ({ - gameIsPaused: { ...state.gameIsPaused, [id]: true }, - }), - () => this.updateToolbar() - ); + // Pause status is transmitted by the game (using `status`). }; _refresh = (id: DebuggerId) => { @@ -345,7 +341,7 @@ export default class Debugger extends React.Component { debuggerServerError, debuggerServerState, selectedId, - debuggerIds, + debuggerStatus, debuggerGameData, profilerOutputs, profilingInProgress, @@ -375,7 +371,7 @@ export default class Debugger extends React.Component { this.setState( { diff --git a/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js new file mode 100644 index 000000000000..9edec2e8a403 --- /dev/null +++ b/newIDE/app/src/EmbeddedGame/EmbeddedGameFrame.js @@ -0,0 +1,115 @@ +// @flow +import * as React from 'react'; +import { type PreviewDebuggerServer } from '../ExportAndShare/PreviewLauncher.flow'; + +type AttachToPreviewOptions = {| + previewIndexHtmlLocation: string, +|}; + +type SwitchToSceneEditionOptions = {| + sceneName: string, + externalLayoutName?: string, +|}; + +let onAttachToPreview: null | (AttachToPreviewOptions => void) = null; +let onSwitchToSceneEdition: null | (SwitchToSceneEditionOptions => void) = null; + +export const attachToPreview = ({ + previewIndexHtmlLocation, +}: AttachToPreviewOptions) => { + if (!onAttachToPreview) throw new Error('No EmbeddedGameFrame registered.'); + onAttachToPreview({ previewIndexHtmlLocation }); +}; + +export const switchToSceneEdition = ({ + sceneName, + externalLayoutName, +}: SwitchToSceneEditionOptions) => { + if (!onSwitchToSceneEdition) + throw new Error('No EmbeddedGameFrame registered.'); + onSwitchToSceneEdition({ sceneName, externalLayoutName }); +}; + +type Props = {| + previewDebuggerServer: PreviewDebuggerServer | null, + onLaunchPreviewForInGameEdition: ({| + sceneName: string, + externalLayoutName: ?string, + |}) => void, +|}; + +export const EmbeddedGameFrame = ({ + previewDebuggerServer, + onLaunchPreviewForInGameEdition, +}: Props) => { + const [ + previewIndexHtmlLocation, + setPreviewIndexHtmlLocation, + ] = React.useState(''); + const iframeRef = React.useRef(null); + + React.useEffect( + () => { + // TODO: use a real context for this? + onAttachToPreview = (options: AttachToPreviewOptions) => { + setPreviewIndexHtmlLocation(options.previewIndexHtmlLocation); + if (iframeRef.current) { + iframeRef.current.contentWindow.focus(); + } + }; + onSwitchToSceneEdition = (options: SwitchToSceneEditionOptions) => { + if (!previewDebuggerServer) return; + + const { sceneName, externalLayoutName } = options; + + if (!previewIndexHtmlLocation) { + 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 { + 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: 'switchForInGameEdition', + sceneName, + externalLayoutName, + }); + }); + } + }; + }, + [ + previewDebuggerServer, + previewIndexHtmlLocation, + onLaunchPreviewForInGameEdition, + ] + ); + + return ( +
+
+