diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index 048ddef2c0b0..b3e734b43104 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -492,17 +492,22 @@ void ExporterHelper::SerializeRuntimeGameOptions( } // Pass in the options the list of scripts files - useful for hot-reloading. - auto &scriptFilesElement = runtimeGameOptions.AddChild("scriptFiles"); - scriptFilesElement.ConsiderAsArrayOf("scriptFile"); - - for (const auto &includeFile : includesFiles) { - auto hashIt = options.includeFileHashes.find(includeFile); - gd::String scriptSrc = GetExportedIncludeFilename(fs, gdjsRoot, includeFile); - scriptFilesElement.AddChild("scriptFile") - .SetStringAttribute("path", scriptSrc) - .SetIntAttribute( - "hash", - hashIt != options.includeFileHashes.end() ? hashIt->second : 0); + // If includeFiles is empty, it means that the include files have not been + // generated, so do not even add them to the runtime game options, so the + // hot-reloader will not try to reload them. + if (!includesFiles.empty()) { + auto &scriptFilesElement = runtimeGameOptions.AddChild("scriptFiles"); + scriptFilesElement.ConsiderAsArrayOf("scriptFile"); + + for (const auto &includeFile : includesFiles) { + auto hashIt = options.includeFileHashes.find(includeFile); + gd::String scriptSrc = GetExportedIncludeFilename(fs, gdjsRoot, includeFile); + scriptFilesElement.AddChild("scriptFile") + .SetStringAttribute("path", scriptSrc) + .SetIntAttribute( + "hash", + hashIt != options.includeFileHashes.end() ? hashIt->second : 0); + } } } diff --git a/GDJS/Runtime/ResourceLoader.ts b/GDJS/Runtime/ResourceLoader.ts index 16fc5d6b754c..735574d26095 100644 --- a/GDJS/Runtime/ResourceLoader.ts +++ b/GDJS/Runtime/ResourceLoader.ts @@ -716,7 +716,11 @@ namespace gdjs { */ getFullUrl(url: string) { if (this._runtimeGame.isInGameEdition()) { - url = addSearchParameterToUrl(url, 'cache', '' + Date.now()); + // Avoid adding cache burst to URLs which are assumed to be immutable files, + // to avoid costly useless requests each time the game is hot-reloaded. + if (url.startsWith('file://')) { + url = addSearchParameterToUrl(url, 'cache', '' + Date.now()); + } } const { gdevelopResourceToken } = this._runtimeGame._options; if (!gdevelopResourceToken) return url; diff --git a/GDJS/Runtime/debugger-client/hot-reloader.ts b/GDJS/Runtime/debugger-client/hot-reloader.ts index 04caf6e93545..44f6adca9d5d 100644 --- a/GDJS/Runtime/debugger-client/hot-reloader.ts +++ b/GDJS/Runtime/debugger-client/hot-reloader.ts @@ -11,6 +11,31 @@ namespace gdjs { behaviorTypeName: string; }; + /** Each hot-reload has a unique ID to ease the debugging/reading logs. */ + let nextHotReloadId = 1; + + type HotReloadOptions = { + shouldReloadResources: boolean; + projectData: ProjectData; + runtimeGameOptions: RuntimeGameOptions; + }; + + const cloneHotReloadOptions = ( + options: HotReloadOptions + ): HotReloadOptions => { + return JSON.parse(JSON.stringify(options)); + }; + + const getOptionsLogString = (options: HotReloadOptions): string => { + return JSON.stringify({ + shouldReloadResources: options.shouldReloadResources, + shouldReloadLibraries: options.runtimeGameOptions.shouldReloadLibraries, + shouldGenerateScenesEventsCode: + options.runtimeGameOptions.shouldGenerateScenesEventsCode, + newScriptFilesCount: options.runtimeGameOptions.scriptFiles?.length, + }); + }; + /** * Reload scripts/data of an exported game and applies the changes * to the running runtime game. @@ -20,13 +45,25 @@ namespace gdjs { _reloadedScriptElement: Record = {}; _logs: HotReloaderLog[] = []; _alreadyLoadedScriptFiles: Record = {}; - _isHotReloading: boolean = false; + _existingScriptFiles: RuntimeGameOptionsScriptFile[] | null = null; + _isHotReloadingSince: number | null = null; + + _hotReloadsQueue: Array<{ + hotReloadId: number; + onDone: (logs: HotReloaderLog[]) => void; + options: HotReloadOptions; + }> = []; /** * @param runtimeGame - The `gdjs.RuntimeGame` to be hot-reloaded. */ constructor(runtimeGame: gdjs.RuntimeGame) { this._runtimeGame = runtimeGame; + + // Remember the script files that were loaded when the game was started. + if (this._runtimeGame._options.scriptFiles) { + this._existingScriptFiles = this._runtimeGame._options.scriptFiles; + } } static indexByPersistentUuid< @@ -145,23 +182,57 @@ namespace gdjs { }); } - async hotReload({ - shouldReloadResources, - projectData: newProjectData, - runtimeGameOptions: newRuntimeGameOptions, - }: { - shouldReloadResources: boolean; - projectData: ProjectData; - runtimeGameOptions: RuntimeGameOptions; - }): Promise { - if (this._isHotReloading) { - console.error('Hot reload already in progress, skipping.'); - return []; + /** + * Trigger a hot-reload of the game. + * The hot-reload is added to a queue and processed in order. + * + * This allows the editor to trigger multiple hot-reloads in a row (even if + * it's sub-optimal) and not miss any (one could for example be reloading libraries + * or code, while other are just reloading resources). + */ + async hotReload(options: HotReloadOptions): Promise { + return new Promise((resolve) => { + const hotReloadId = nextHotReloadId++; + + this._hotReloadsQueue.push({ + hotReloadId, + onDone: resolve, + // Clone the options to avoid any mutation while + // waiting for the hot-reload to be processed. + options: cloneHotReloadOptions(options), + }); + + if (this._hotReloadsQueue.length > 1) { + logger.info( + `Hot reload #${hotReloadId} added to queue. Options are: ${getOptionsLogString(options)}.` + ); + } + + this._processHotReloadsQueue(); + }); + } + + private async _processHotReloadsQueue(): Promise { + // Don't do anything if a hot-reload is already in progress: + // it will be processed later (see the end). + if (this._isHotReloadingSince || this._hotReloadsQueue.length === 0) { + return; } - this._isHotReloading = true; + // Mark the hot reload as started (so no other hot-reload is started). + this._isHotReloadingSince = Date.now(); + + const { options, onDone, hotReloadId } = this._hotReloadsQueue.shift()!; + const { + shouldReloadResources, + projectData: newProjectData, + runtimeGameOptions: newRuntimeGameOptions, + } = options; + + logger.info( + `Hot reload #${hotReloadId} started. Options are: ${getOptionsLogString(options)}.` + ); - logger.info('Hot reload started'); const wasPaused = this._runtimeGame.isPaused(); this._runtimeGame.pause(true); this._logs = []; @@ -171,17 +242,13 @@ namespace gdjs { const oldProjectData: ProjectData = gdjs.projectData; gdjs.projectData = newProjectData; - const oldRuntimeGameOptions = gdjs.runtimeGameOptions; - gdjs.runtimeGameOptions = newRuntimeGameOptions; - - const oldScriptFiles = - oldRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[]; - - oldScriptFiles.forEach((scriptFile) => { + (this._existingScriptFiles || []).forEach((scriptFile) => { this._alreadyLoadedScriptFiles[scriptFile.path] = true; }); - const oldBehaviorConstructors: { [key: string]: Function } = {}; + gdjs.runtimeGameOptions = newRuntimeGameOptions; + + const oldBehaviorConstructors: { [key: string]: Function } = {}; for (let behaviorTypeName in gdjs.behaviorsTypes.items) { oldBehaviorConstructors[behaviorTypeName] = gdjs.behaviorsTypes.items[behaviorTypeName]; @@ -195,8 +262,7 @@ namespace gdjs { ); } - const newScriptFiles = - newRuntimeGameOptions.scriptFiles as RuntimeGameOptionsScriptFile[]; + const newScriptFiles = newRuntimeGameOptions.scriptFiles; const shouldGenerateScenesEventsCode = !!newRuntimeGameOptions.shouldGenerateScenesEventsCode; const shouldReloadLibraries = @@ -210,7 +276,7 @@ namespace gdjs { if (shouldReloadLibraries) { await this.reloadScriptFiles( newProjectData, - oldScriptFiles, + this._existingScriptFiles, newScriptFiles, shouldGenerateScenesEventsCode ); @@ -278,13 +344,32 @@ namespace gdjs { } } - this._isHotReloading = false; + // Remember the script files that were loaded for the game now that + // the hot-reload is finished. This will allow a next hot-reload to + // reload the scripts files that have been added or changed. + // Note that some hot-reload options do not have any "scriptFiles", in which + // case the game script files have not changed. + if (newRuntimeGameOptions.scriptFiles) { + this._existingScriptFiles = newRuntimeGameOptions.scriptFiles; + } + logger.info( - 'Hot reload finished with logs:', - this._logs.map((log) => '\n' + log.kind + ': ' + log.message) + `Hot reload #${hotReloadId} finished in ${Math.ceil(Date.now() - this._isHotReloadingSince)}ms with logs:\n${ + this._logs.length > 0 + ? this._logs.map((log) => '\n' + log.kind + ': ' + log.message) + : '(no logs)' + }` ); + this._isHotReloadingSince = null; this._runtimeGame.pause(wasPaused); - return this._logs; + onDone(this._logs); + + if (this._hotReloadsQueue.length > 0) { + logger.info( + `Still ${this._hotReloadsQueue.length} hot-reloads in queue. Starting the next one...` + ); + this._processHotReloadsQueue(); + } } _computeChangedRuntimeBehaviors( @@ -327,8 +412,8 @@ namespace gdjs { reloadScriptFiles( newProjectData: ProjectData, - oldScriptFiles: RuntimeGameOptionsScriptFile[], - newScriptFiles: RuntimeGameOptionsScriptFile[], + oldScriptFiles: RuntimeGameOptionsScriptFile[] | null, + newScriptFiles: RuntimeGameOptionsScriptFile[] | undefined, shouldGenerateScenesEventsCode: boolean ): Promise { const reloadPromises: Array> = []; @@ -339,46 +424,67 @@ namespace gdjs { reloadPromises.push(this._reloadScript('code' + index + '.js')); }); } - for (let i = 0; i < newScriptFiles.length; ++i) { - const newScriptFile = newScriptFiles[i]; - const oldScriptFile = oldScriptFiles.filter( - (scriptFile) => scriptFile.path === newScriptFile.path - )[0]; - if (!oldScriptFile) { - // Script file added - this._logs.push({ - kind: 'info', - message: - 'Loading ' + - newScriptFile.path + - ' as it was added to the list of scripts.', - }); - reloadPromises.push(this._reloadScript(newScriptFile.path)); - } else { - // Script file changed, which can be the case for extensions created - // from the editor, containing free functions or behaviors. - if (newScriptFile.hash !== oldScriptFile.hash) { + + if (!newScriptFiles) { + // Script files were not exported for this hot-reload. + // This means the hot-reload was just done for a new resource, object + // or other thing not reload to code generation. Just do nothing. + logger.info( + 'Script files were not exported (previously or now for this hot-reload).' + ); + } else if (!oldScriptFiles) { + // Script files are not available. This is suspicious as we should always + // have them stored. + logger.error( + 'Existing script files are not available for the hot-reload. No new or modified script will be hot-reloaded.' + ); + + // TODO: Consider if this should be communicated as an error or fatal error. + } else { + for (let i = 0; i < newScriptFiles.length; ++i) { + const newScriptFile = newScriptFiles[i]; + const oldScriptFile = oldScriptFiles.filter( + (scriptFile) => scriptFile.path === newScriptFile.path + )[0]; + if (!oldScriptFile) { + // Script file added this._logs.push({ kind: 'info', message: - 'Reloading ' + newScriptFile.path + ' because it was changed.', + 'Loading ' + + newScriptFile.path + + ' as it was added to the list of scripts.', }); reloadPromises.push(this._reloadScript(newScriptFile.path)); + } else { + // Script file changed, which can be the case for extensions created + // from the editor, containing free functions or behaviors. + if (newScriptFile.hash !== oldScriptFile.hash) { + this._logs.push({ + kind: 'info', + message: + 'Reloading ' + + newScriptFile.path + + ' because it was changed.', + }); + reloadPromises.push(this._reloadScript(newScriptFile.path)); + } } } - } - for (let i = 0; i < oldScriptFiles.length; ++i) { - const oldScriptFile = oldScriptFiles[i]; - const newScriptFile = newScriptFiles.filter( - (scriptFile) => scriptFile.path === oldScriptFile.path - )[0]; + for (let i = 0; i < oldScriptFiles.length; ++i) { + const oldScriptFile = oldScriptFiles[i]; + const newScriptFile = newScriptFiles.filter( + (scriptFile) => scriptFile.path === oldScriptFile.path + )[0]; - // A file may be removed because of a partial preview. - if (!newScriptFile && !shouldGenerateScenesEventsCode) { - this._logs.push({ - kind: 'warning', - message: 'Script file ' + oldScriptFile.path + ' was removed.', - }); + // A file may be removed because of a partial preview. + if (!newScriptFile && !shouldGenerateScenesEventsCode) { + this._logs.push({ + kind: 'warning', + message: + 'Script file ' + oldScriptFile.path + ' was removed. Ignoring.', + }); + } } } return Promise.all(reloadPromises); diff --git a/GDJS/Runtime/pixi-renderers/loadingscreen-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/loadingscreen-pixi-renderer.ts index 3291b84d6b24..174d89ba2b33 100644 --- a/GDJS/Runtime/pixi-renderers/loadingscreen-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/loadingscreen-pixi-renderer.ts @@ -312,4 +312,5 @@ namespace gdjs { //Register the class to let the engine use it. export const LoadingScreenRenderer = LoadingScreenPixiRenderer; + export type LoadingScreenRenderer = LoadingScreenPixiRenderer; } diff --git a/GDJS/Runtime/runtimegame.ts b/GDJS/Runtime/runtimegame.ts index abb091061b8f..e205caaf4e8e 100644 --- a/GDJS/Runtime/runtimegame.ts +++ b/GDJS/Runtime/runtimegame.ts @@ -210,6 +210,7 @@ namespace gdjs { * Game loop management (see startGameLoop method) */ _renderer: RuntimeGameRenderer; + _displayedLoadingScreen: gdjs.LoadingScreenRenderer | null = null; _sessionId: string | null; _playerId: string | null; _watermark: watermark.RuntimeWatermark; @@ -1046,6 +1047,7 @@ namespace gdjs { this._data.properties.watermark.showWatermark, isFirstScene ); + this._displayedLoadingScreen = loadingScreen; const onProgress = async (count: integer, total: integer) => { const percent = Math.floor((100 * count) / total); @@ -1063,6 +1065,7 @@ namespace gdjs { await loadingScreen.unload(); + this._displayedLoadingScreen = null; if (!this._isInGameEdition) { this.pause(false); } @@ -1204,12 +1207,19 @@ namespace gdjs { // Render and possibly step the game. if (this._paused) { if (this._inGameEditor) { - // The game is paused for edition: the in-game editor runs and render - // the scene. - this._inGameEditor.updateAndRender(); + 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 { diff --git a/newIDE/app/src/AssetStore/AssetPackInstallDialog.js b/newIDE/app/src/AssetStore/AssetPackInstallDialog.js index bfa5f3ef8d37..e7a896fa226b 100644 --- a/newIDE/app/src/AssetStore/AssetPackInstallDialog.js +++ b/newIDE/app/src/AssetStore/AssetPackInstallDialog.js @@ -17,6 +17,8 @@ import { checkRequiredExtensionsUpdateForAssets, installPublicAsset, complyVariantsToEventsBasedObjectOf, + type AddAssetOutput, + type InstallAssetOutput, } from './InstallAsset'; import { useInstallExtension } from './ExtensionStore/InstallExtension'; import { showErrorBox } from '../UI/Messages/MessageBox'; @@ -31,9 +33,11 @@ import { mapFor } from '../Utils/MapFor'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import AlertMessage from '../UI/AlertMessage'; import { useFetchAssets } from './NewObjectDialog'; -import { type InstallAssetOutput } from './InstallAsset'; import { type ObjectFolderOrObjectWithContext } from '../ObjectsList/EnumerateObjectFolderOrObject'; import { ExtensionStoreContext } from './ExtensionStore/ExtensionStoreContext'; +import uniq from 'lodash/uniq'; + +const gd: libGDevelop = global.gd; // We limit the number of assets that can be installed at once to avoid // timeouts especially with premium packs. @@ -174,10 +178,22 @@ const AssetPackInstallDialog = ({ return; } + const isTheFirstOfItsTypeInProject = uniq( + assets + .map(asset => + asset.objectAssets.map(objectAsset => objectAsset.object.type) + ) + .flat() + .filter(objectType => !!objectType) + ).some( + objectType => + !gd.UsedObjectTypeFinder.scanProject(project, objectType) + ); + // Use a pool to avoid installing an unbounded amount of assets at the same time. const { errors, results } = await PromisePool.withConcurrency(6) .for(assets) - .process(async asset => { + .process(async asset => { const isAssetCompatibleWithIde = asset.objectAssets.every( objectAsset => !objectAsset.requiredExtensions || @@ -198,7 +214,7 @@ const AssetPackInstallDialog = ({ const doInstall = isPrivateAsset(asset) ? installPrivateAsset : installPublicAsset; - const installOutput = await doInstall({ + const addAssetOutput = await doInstall({ asset, project, objectsContainer: targetObjectsContainer, @@ -209,10 +225,10 @@ const AssetPackInstallDialog = ({ : null, }); - if (!installOutput) { + if (!addAssetOutput) { throw new Error('Unable to install the asset.'); } - return installOutput; + return addAssetOutput; }); if (errors.length) { @@ -229,13 +245,6 @@ const AssetPackInstallDialog = ({ const createdObjects = results .map(result => result.createdObjects) .flat(); - const isTheFirstOfItsTypeInProject = results - .map(result => result.isTheFirstOfItsTypeInProject) - .reduce( - (accumulator: boolean, currentValue: boolean) => - accumulator || currentValue, - false - ); complyVariantsToEventsBasedObjectOf(project, createdObjects); onAssetsAdded({ createdObjects, isTheFirstOfItsTypeInProject }); } catch (error) { diff --git a/newIDE/app/src/AssetStore/InstallAsset.js b/newIDE/app/src/AssetStore/InstallAsset.js index a0f3b30a4cdc..7f035a7aa6ef 100644 --- a/newIDE/app/src/AssetStore/InstallAsset.js +++ b/newIDE/app/src/AssetStore/InstallAsset.js @@ -112,6 +112,10 @@ export const installResource = ( resourceNewNames[originalResourceName] = newName; }; +export type AddAssetOutput = {| + createdObjects: Array, +|}; + export type InstallAssetOutput = {| createdObjects: Array, isTheFirstOfItsTypeInProject: boolean, @@ -148,7 +152,7 @@ export const addAssetToProject = async ({ objectsContainer, targetObjectFolderOrObject, requestedObjectName, -}: InstallAssetArgs): Promise => { +}: InstallAssetArgs): Promise => { const objectNewNames = {}; const resourceNewNames = {}; const createdObjects: Array = []; @@ -323,8 +327,6 @@ export const addAssetToProject = async ({ return { createdObjects, - // This boolean is set by `useInstallAsset` - isTheFirstOfItsTypeInProject: false, }; }; diff --git a/newIDE/app/src/AssetStore/NewObjectDialog.js b/newIDE/app/src/AssetStore/NewObjectDialog.js index da78720aea0e..ba7947b15e58 100644 --- a/newIDE/app/src/AssetStore/NewObjectDialog.js +++ b/newIDE/app/src/AssetStore/NewObjectDialog.js @@ -186,7 +186,7 @@ export const useInstallAsset = ({ ); const isPrivate = isPrivateAsset(assetShortHeader); - const installOutput = isPrivate + const addAssetOutput = isPrivate ? await installPrivateAsset({ asset, project, @@ -209,7 +209,7 @@ export const useInstallAsset = ({ ? targetObjectFolderOrObjectWithContext.objectFolderOrObject : null, }); - if (!installOutput) { + if (!addAssetOutput) { throw new Error('Unable to install private Asset.'); } sendAssetAddedToProject({ @@ -223,13 +223,16 @@ export const useInstallAsset = ({ }); complyVariantsToEventsBasedObjectOf( project, - installOutput.createdObjects + addAssetOutput.createdObjects ); await resourceManagementProps.onFetchNewlyAddedResources(); resourceManagementProps.onNewResourcesAdded(); - installOutput.isTheFirstOfItsTypeInProject = isTheFirstOfItsTypeInProject; - return installOutput; + + return { + createdObjects: addAssetOutput.createdObjects, + isTheFirstOfItsTypeInProject, + }; } catch (error) { console.error('Error while installing the asset:', error); showAlert({ diff --git a/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationContext.js b/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationContext.js index 059e0c317214..5f9d52058624 100644 --- a/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationContext.js +++ b/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationContext.js @@ -5,10 +5,7 @@ import { type Asset, type Environment, } from '../../Utils/GDevelopServices/Asset'; -import { - type InstallAssetOutput, - type InstallAssetArgs, -} from '../InstallAsset'; +import { type AddAssetOutput, type InstallAssetArgs } from '../InstallAsset'; export type PrivateAssetsState = {| authorizationToken: ?string, @@ -17,9 +14,7 @@ export type PrivateAssetsState = {| assetShortHeader: AssetShortHeader, options: {| environment: Environment |} ) => Promise, - installPrivateAsset: ( - options: InstallAssetArgs - ) => Promise, + installPrivateAsset: (options: InstallAssetArgs) => Promise, getPrivateAssetPackAudioArchiveUrl: ( privateAssetPackId: string ) => Promise, diff --git a/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationProvider.js b/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationProvider.js index 73d0e1c9de9a..01bbf024b5bc 100644 --- a/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationProvider.js +++ b/newIDE/app/src/AssetStore/PrivateAssets/PrivateAssetsAuthorizationProvider.js @@ -10,7 +10,7 @@ import { } from '../../Utils/GDevelopServices/Asset'; import { addAssetToProject, - type InstallAssetOutput, + type AddAssetOutput, type InstallAssetArgs, } from '../InstallAsset'; import { @@ -116,7 +116,7 @@ const PrivateAssetsAuthorizationProvider = ({ children }: Props) => { objectsContainer, requestedObjectName, targetObjectFolderOrObject, - }: InstallAssetArgs): Promise => { + }: InstallAssetArgs): Promise => { if (!profile) { throw new Error( 'Unable to install the asset because no profile was found.' diff --git a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js index 51d3d3189c11..96c0d5727da5 100644 --- a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js +++ b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/index.js @@ -25,6 +25,8 @@ import { setEmbeddedGameFramePreviewLocation } from '../../../EmbeddedGame/Embed import { immediatelyOpenNewPreviewWindow } from '../BrowserPreview/BrowserPreviewWindow'; const gd: libGDevelop = global.gd; +let nextPreviewId = 1; + const prepareExporter = async ({ isForInGameEdition, }: { @@ -122,10 +124,17 @@ export default class BrowserSWPreviewLauncher extends React.Component< eventsBasedObjectVariantName, previewWindows, } = previewOptions; + const previewStartTime = Date.now(); this.setState({ error: null, }); + const previewId = nextPreviewId++; + console.log( + `[BrowserSWPreviewLauncher] Launching preview #${previewId} with options:`, + previewOptions + ); + const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds(); const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length; @@ -267,12 +276,12 @@ export default class BrowserSWPreviewLauncher extends React.Component< previewExportOptions.setGDevelopResourceToken(gdevelopResourceToken); console.log( - '[BrowserSWPreviewLauncher] Exporting project for preview...' + `[BrowserSWPreviewLauncher] Exporting project for preview #${previewId}...` ); exporter.exportProjectForPixiPreview(previewExportOptions); console.log( - '[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...' + `[BrowserSWPreviewLauncher] Storing preview files in IndexedDB for preview #${previewId}...` ); await browserSWFileSystem.applyPendingOperations(); @@ -305,14 +314,18 @@ export default class BrowserSWPreviewLauncher extends React.Component< runtimeGameOptionsElement.delete(); if (previewOptions.shouldHardReload) { - console.log('[BrowserSWPreviewLauncher] Triggering hard reload...'); + console.log( + `[BrowserSWPreviewLauncher] Triggering hard reload for preview #${previewId}...` + ); debuggerIds.forEach(debuggerId => { this.getPreviewDebuggerServer().sendMessage(debuggerId, { command: 'hardReload', }); }); } else { - console.log('[BrowserSWPreviewLauncher] Triggering hot reload...'); + console.log( + `[BrowserSWPreviewLauncher] Triggering hot reload for preview #${previewId}...` + ); debuggerIds.forEach(debuggerId => { this.getPreviewDebuggerServer().sendMessage(debuggerId, { command: 'hotReload', @@ -373,10 +386,14 @@ export default class BrowserSWPreviewLauncher extends React.Component< previewExportOptions.delete(); exporter.delete(); - console.log('[BrowserSWPreviewLauncher] Preview launched successfully!'); + console.log( + `[BrowserSWPreviewLauncher] Preview ${previewId} launched successfully in ${Math.ceil( + Date.now() - previewStartTime + )}ms.` + ); } catch (error) { console.error( - '[BrowserSWPreviewLauncher] Error launching preview:', + `[BrowserSWPreviewLauncher] Error launching preview ${previewId}:`, error ); this.setState({ diff --git a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js index adf89c2046fe..2f7695cb2923 100644 --- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js +++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalPreviewLauncher/index.js @@ -27,6 +27,8 @@ const path = optionalRequire('path'); const ipcRenderer = electron ? electron.ipcRenderer : null; const gd: libGDevelop = global.gd; +let nextPreviewId = 1; + type State = {| networkPreviewDialogOpen: boolean, networkPreviewHost: ?string, @@ -222,6 +224,12 @@ export default class LocalPreviewLauncher extends React.Component< eventsBasedObjectVariantName, } = previewOptions; + const previewId = nextPreviewId++; + console.log( + `[LocalPreviewLauncher] Launching preview #${previewId} with options:`, + previewOptions + ); + // Start the debugger server for previews. Even if not used, // useful if the user opens the Debugger editor later, or want to // hot reload. @@ -392,6 +400,9 @@ export default class LocalPreviewLauncher extends React.Component< runtimeGameOptionsElement.delete(); if (previewOptions.shouldHardReload) { + console.log( + `[LocalPreviewLauncher] Triggering hard reload for preview #${previewId}...` + ); debuggerIds.forEach(debuggerId => { this.getPreviewDebuggerServer().sendMessage(debuggerId, { command: 'hardReload', @@ -399,6 +410,9 @@ export default class LocalPreviewLauncher extends React.Component< }); } else { debuggerIds.forEach(debuggerId => { + console.log( + `[LocalPreviewLauncher] Triggering hot reload for preview #${previewId}...` + ); this.getPreviewDebuggerServer().sendMessage(debuggerId, { command: 'hotReload', payload: { @@ -436,7 +450,10 @@ export default class LocalPreviewLauncher extends React.Component< previewExportOptions.delete(); const previewStopTime = performance.now(); - console.info(`Preview took ${previewStopTime - previewStartTime}ms`); + console.info( + `[LocalPreviewLauncher] Preview #${previewId} took ${previewStopTime - + previewStartTime}ms` + ); }; getPreviewDebuggerServer() { diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 89f730fe8d62..b33af422ea53 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -319,6 +319,24 @@ const initialPreviewState: PreviewState = { overridenPreviewExternalLayoutName: null, }; +const usePreviewLoadingState = () => { + const forceUpdate = useForceUpdate(); + const previewLoadingRef = React.useRef< + null | 'preview' | 'hot-reload-for-in-game-edition' + >(null); + + return { + previewLoadingRef, + setPreviewLoading: React.useCallback( + (previewLoading: null | 'preview' | 'hot-reload-for-in-game-edition') => { + previewLoadingRef.current = previewLoading; + forceUpdate(); + }, + [forceUpdate] + ), + }; +}; + export type Props = {| renderMainMenu?: ( BuildMainMenuProps, @@ -437,9 +455,7 @@ const MainFrame = (props: Props) => { } = useAlertDialog(); const preferences = React.useContext(PreferencesContext); const { setHasProjectOpened } = preferences; - const [previewLoading, setPreviewLoading] = React.useState< - null | 'preview' | 'hot-reload-for-in-game-edition' - >(null); + const { previewLoadingRef, setPreviewLoading } = usePreviewLoadingState(); const [previewState, setPreviewState] = React.useState(initialPreviewState); const commandPaletteRef = React.useRef((null: ?CommandPaletteInterface)); const inAppTutorialOrchestratorRef = React.useRef( @@ -2131,13 +2147,33 @@ const MainFrame = (props: Props) => { if (!currentProject) return; if (currentProject.getLayoutsCount() === 0) return; + console.info( + `Launching a new ${ + isForInGameEdition ? 'in-game edition preview' : 'preview' + } with options:`, + { + networkPreview, + numberOfWindows, + hotReload, + shouldReloadProjectData, + shouldReloadLibraries, + shouldGenerateScenesEventsCode, + shouldReloadResources, + shouldHardReload, + fullLoadingScreen, + forceDiagnosticReport, + launchCaptureOptions, + isForInGameEdition, + } + ); + const previewLauncher = _previewLauncher.current; if (!previewLauncher) { console.error('Preview launcher not found.'); return; } - if (previewLoading) { + if (previewLoadingRef.current) { console.error( 'Preview already loading. Ignoring but it should not be even possible to launch a preview while another one is loading, as this could break the game of the first preview when it is loading or reading files.' ); @@ -2265,6 +2301,7 @@ const MainFrame = (props: Props) => { previewWindows, }); + setPreviewLoading(null); if (!isForInGameEdition) @@ -2325,7 +2362,8 @@ const MainFrame = (props: Props) => { onCaptureFinished, createCaptureOptionsForPreview, inGameEditorSettings, - previewLoading, + previewLoadingRef, + setPreviewLoading, ] ); @@ -4628,6 +4666,7 @@ const MainFrame = (props: Props) => { onOpenProfileDialog, }); + const previewLoading = previewLoadingRef.current; const hideAskAi = !!authenticatedUser.limits && !!authenticatedUser.limits.capabilities.classrooms && diff --git a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js index 880a20780adb..483e1ad70b86 100644 --- a/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js +++ b/newIDE/app/src/stories/componentStories/AssetStore/AssetStore/AssetPackInstallDialog.stories.js @@ -191,7 +191,6 @@ export const LayoutPrivateAssetInstallSuccess = () => { installPrivateAsset: async () => ({ // Mock a successful installation createdObjects: [], - isTheFirstOfItsTypeInProject: false, }), getPrivateAssetPackAudioArchiveUrl: async () => 'https://resources.gevelop.io/path/to/audio/archive',